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.

Create a free account to save this page, take notes and track your progress. Sign in

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

A props leak, a DTO, and a tampered Server Action
// 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 /invoices

Output

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 undefined in the browser) or prefixing secrets with NEXT_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; use server-only for 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.

Was this page helpful?