Next.js App Router in Production · Streaming, Security and Operations
Data Security: DTOs, Tainted Props and Public Server Actions
Prevent data leaks through Client Component props, return DTOs from a data access layer, and authorize Server Actions against tampered input.
What you'll build
Two versions of a profile page: one passes the full user record to a Client Component and leaks the password hash and internal notes in the page payload even though they're never displayed; the other passes a DTO. Then an invoices page whose cancel action is called with a tampered invoice ID, which the action rejects because it checks ownership on the server.
The idea
- Everything passed as props to a Client Component is serialized into the HTML response (the RSC payload), visible in "view source", whether or not it's rendered.
- A data access layer returns DTOs: only fields the caller may see, after authorization checks.
- Server Actions receive whatever the client sends: hidden inputs and IDs can be changed. Authorize every action against the session and the resource owner.
- Only
NEXT_PUBLIC_environment variables are inlined into client bundles; everything else stays on the server.
The code
// file: next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;
// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><main>{children}</main></body>
</html>
);
}
// file: app/lib/db.ts
import "server-only";
export const users = new Map([
["u1", { id: "u1", name: "Ana", passwordHash: "scrypt$9f2c1a77", internalNotes: "VIP, refund approved" }],
]);
const store = globalThis as unknown as { invoices?: Map<string, { id: string; ownerId: string; status: string }> };
store.invoices ??= new Map([
["inv-1", { id: "inv-1", ownerId: "u1", status: "open" }],
["inv-9", { id: "inv-9", ownerId: "u2", status: "open" }],
]);
export const invoices = store.invoices;
// file: app/lib/dal.ts
import "server-only";
import { cookies } from "next/headers";
import { users, invoices } from "./db";
export async function getSession() {
// Verified signed session as in the previous lesson; shortened here.
const userId = (await cookies()).get("session")?.value;
return userId && users.has(userId) ? { userId } : null;
}
export async function getProfileDTO(userId: string) {
const user = users.get(userId)!;
return { name: user.name }; // never the hash or internal notes
}
export async function getMyInvoices() {
const session = await getSession();
if (!session) return [];
return [...invoices.values()].filter((i) => i.ownerId === session.userId);
}
// file: app/profile-card.tsx
"use client";
export function ProfileCard({ user }: { user: { name: string } }) {
return <p>Signed in as {user.name}</p>;
}
// file: app/leaky/page.tsx
import { users } from "../lib/db";
import { ProfileCard } from "../profile-card";
export default function Page() {
const user = users.get("u1")!;
return <ProfileCard user={user} />; // the whole record is serialized into the page
}
// file: app/safe/page.tsx
import { getProfileDTO } from "../lib/dal";
import { ProfileCard } from "../profile-card";
export default async function Page() {
return <ProfileCard user={await getProfileDTO("u1")} />;
}
// file: app/invoices/actions.ts
"use server";
import { refresh } from "next/cache";
import { getSession } from "../lib/dal";
import { invoices } from "../lib/db";
export async function cancelInvoice(formData: FormData) {
const session = await getSession();
const invoice = invoices.get(String(formData.get("invoiceId")));
if (!session || !invoice || invoice.ownerId !== session.userId) {
console.log(`[app] refused: ${session?.userId ?? "anonymous"} tried to cancel ${formData.get("invoiceId")}`);
return;
}
invoice.status = "cancelled";
console.log(`[app] ${session.userId} cancelled ${invoice.id}`);
refresh();
}
// file: app/invoices/page.tsx
import { Suspense } from "react";
import { getMyInvoices } from "../lib/dal";
import { cancelInvoice } from "./actions";
async function InvoiceList() {
const list = await getMyInvoices();
return (
<ul>
{list.map((invoice) => (
<li key={invoice.id}>
{invoice.id}: {invoice.status}{" "}
<form action={cancelInvoice}>
<input type="hidden" name="invoiceId" value={invoice.id} />
<button>Cancel</button>
</form>
</li>
))}
</ul>
);
}
export default function Page() {
return <><h1>My invoices</h1><Suspense fallback={<p>Loading...</p>}><InvoiceList /></Suspense></>;
}
// request: GET /leaky find:passwordHash find:internalNotes
// request: GET /safe find:passwordHash find:internalNotes
// request: SUBMIT /invoices cookie:session=u1 invoiceId=inv-9
// request: SUBMIT /invoices
// request: GET /invoicesOutput
GET /leaky -> 200 Signed in as Ana response contains "passwordHash": yes response contains "internalNotes": yes GET /safe -> 200 Signed in as Ana response contains "passwordHash": no response contains "internalNotes": no SUBMIT /invoices invoiceId=inv-9 -> 200 [app] refused: u1 tried to cancel inv-9 SUBMIT /invoices -> 200 [app] u1 cancelled inv-1 GET /invoices -> 200 My invoices inv-1: cancelled Cancel
Run this on your computer — see “Get Started” for setup.
Both profile pages display the same text, but the leaky one ships the password hash and internal notes to every visitor's browser. The tampered submission (another user's invoice ID) is refused; the normal one cancels the user's own invoice.
Common mistakes
- Passing ORM records or API responses straight to Client Components.
- Using IDs from hidden form fields or URL params in Server Actions without checking ownership.
- Reading secrets in client code (they're
undefinedin the browser) or prefixing secrets withNEXT_PUBLIC_to "make them work". - Assuming a page check protects its Server Actions; each action is its own endpoint.
Key points
- Every Client Component prop is in the page payload: pass DTOs.
- Authorize each Server Action against the session and the resource.
- Keep secrets out of
NEXT_PUBLIC_variables; useserver-onlyfor sensitive modules.
Further reading: Data security guide · Environment variables · OWASP Top 10: Broken Access Control
Practice questions
Why can a field that's never displayed still leak?
Answer
Props passed to Client Components are serialized into the RSC payload in the HTML, so the whole object is visible in the page source even if the component renders one field.
What's a DTO in a Next.js data access layer?
Answer
A data transfer object containing only the fields the caller is allowed to see, returned after authorization checks, instead of raw database records.
Which environment variables reach the browser?
Answer
Only those prefixed with NEXT_PUBLIC_, which are inlined into the client bundle at build time; all others are available only on the server.
Open in the app to mark lessons complete, take notes and track your progress.