Next.js NotesRohit’s interview study guide
Chapter 09

Authentication and Security

Sessions in cookies, checks next to the data, 401/403 pages, secrets that stay secret, nonce-based CSP, and the real Next.js vulnerabilities interviewers bring up.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Authentication patterns in the App Router

Added

Authentication in a Next.js app is three separate jobs, and most bugs come from mixing them up. Authentication proves who someone is (email and password, a passkey, "Sign in with Google"). Session management remembers the answer across requests, almost always with a cookie. Authorisation decides, on every request, whether this person may see this data or run this mutation. Next.js gives you no auth system of its own: it gives you the places to put each job. A Server Action or Route Handler checks credentials and sets the cookie; cookies() reads it on the server; the Data Access Layer (DAL) turns it into "who is calling" and "are they allowed".

A good mental model is a hotel key card. The front desk (your login action) checks your passport once and hands you a card. The card is encoded so you can't forge one for the penthouse (the cookie is signed). Every door has its own card reader (every page, Server Action and Route Handler checks the session itself). There's also a guard in the lobby who glances at cards and turns away people with no card at all (proxy): useful, cheap, and not a lock on any door.

There are two ways to store the session, and interviewers like to hear you compare them:

Stateless session (signed or encrypted cookie, JWT)Database session
What the cookie holdsThe session data itself (user id, role, expiry), signed so it can't be editedA random session id; the data lives in a table
Checking itVerify the signature with the secret: no database callLook the id up on every request (cache it per render)
Logging out everywhere, banning a userHard: a valid token stays valid until it expires, unless you add a denylistEasy: delete the row
SizeGrows with the payload (cookies are limited to about 4 KB)Tiny
Typical toolsjose (JWT), iron-session (encrypted), your own HMAC as belowAuth.js database adapter, Better Auth, Lucia-style tables

A JWT is just one format for a stateless token. Put in a cookie with HttpOnly, it behaves like the signed cookie below. Put in localStorage and sent in an Authorization header, it can be read by any script that runs on the page, so one XSS bug can steal it. That's why Next.js apps almost always keep the session in an HttpOnly cookie, even when the token inside is a JWT.

The bundled docs recommend an auth library for real apps (Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js/Auth.js, Ory, Stack Auth, Supabase, Stytch and WorkOS are listed) or a session library (iron-session, jose). To understand what those libraries do, here's a complete session built from Node's own Web Crypto, with no packages: a signed cookie, a login action, and the login page.

lib/session-token.ts
// Signs and checks the session token with Web Crypto (HMAC-SHA256).
// No Next.js imports, so proxy.ts and the server code can both use it.
export type Session = { userId: string; role: "member" | "admin"; expiresAt: number };

const encoder = new TextEncoder();

function getKey() {
  const secret = process.env.SESSION_SECRET;
  if (!secret || secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

export async function signSession(session: Session): Promise<string> {
  const body = Buffer.from(JSON.stringify(session)).toString("base64url");
  const mac = await crypto.subtle.sign("HMAC", await getKey(), encoder.encode(body));
  return `${body}.${Buffer.from(mac).toString("base64url")}`;
}

export async function verifySessionToken(token: string | undefined): Promise<Session | null> {
  if (!token) return null;
  const [body, mac] = token.split(".");
  if (!body || !mac) return null;
  const valid = await crypto.subtle.verify("HMAC", await getKey(), Buffer.from(mac, "base64url"), encoder.encode(body));
  if (!valid) return null; // edited or forged: the signature no longer matches
  const session = JSON.parse(Buffer.from(body, "base64url").toString()) as Session;
  return session.expiresAt > Date.now() ? session : null;
}
What’s happening
  1. getKey() turns SESSION_SECRET into an HMAC-SHA256 key with crypto.subtle.importKey. The secret comes from .env (only server code can read it, see Environment variables and secrets), and the length check makes a missing or weak secret fail loudly instead of signing with undefined.
  2. signSession({ userId: "u1", role: "member", expiresAt }) serialises the object to JSON and base64url-encodes it: that's body. It then computes an HMAC of body with the key: that's mac. The token is body.mac, the same two-part idea as a JWT without the header.
  3. verifySessionToken(token) splits on the dot and asks Web Crypto whether mac is the correct HMAC of body. Only someone holding the secret can produce a matching mac, so if a user edits "role":"member" to "role":"admin" the body changes, the signature no longer matches, and the function returns null.
  4. Only after the signature checks out does it parse the JSON and check expiresAt > Date.now(). The expiry is inside the signed body, so the user can't extend it either; the cookie's own Expires attribute is just a hint to the browser.
  5. The file imports nothing from Next.js on purpose: proxy.ts (which reads request.cookies) and the server code (which uses cookies()) both call these two functions. What this demonstrates: a "stateless session" is nothing magic, just data plus a signature the server can check without a database.
AdvancedThe secret in .env

The secret, the cookie helpers, a stand-in database, the login action and the login page complete the sign-in flow:

.env
# A placeholder for local development only. Real secrets live in .env.local (git-ignored) or the host's settings.
SESSION_SECRET=dev-only-secret-replace-me-0123456789abcdef
AdvancedCookie helpers: create, read, delete
lib/session.ts
import "server-only";
import { cookies } from "next/headers";
import { signSession, verifySessionToken, type Session } from "./session-token";

const COOKIE = "session";
const WEEK = 7 * 24 * 60 * 60 * 1000;

export async function createSession(userId: string, role: Session["role"]) {
  const expiresAt = Date.now() + WEEK;
  const token = await signSession({ userId, role, expiresAt });
  (await cookies()).set(COOKIE, token, {
    httpOnly: true, // page JavaScript can't read it (XSS can't steal it)
    secure: true, // HTTPS only (browsers treat http://localhost as secure)
    sameSite: "lax", // not sent on cross-site POSTs
    path: "/",
    expires: new Date(expiresAt),
  });
}

export async function readSession() {
  return verifySessionToken((await cookies()).get(COOKIE)?.value);
}

export async function deleteSession() {
  (await cookies()).delete(COOKIE);
}
What’s happening
  1. import "server-only" makes the build fail if a Client Component ever imports this file (the error is shown in Environment variables and secrets). Session code that reads the secret must never reach a browser bundle.
  2. createSession(userId, role) signs a token that expires in seven days and stores it with (await cookies()).set(...). In Next.js 15 and 16 cookies() returns a Promise; in 13 and 14 it was synchronous, and 16 removed the synchronous compatibility completely.
  3. The options are the ones the bundled docs recommend: httpOnly so page scripts can't read it, secure so it only travels over HTTPS, sameSite: "lax" so the browser doesn't attach it to cross-site POSTs (a big part of CSRF protection), path: "/", and an expiry.
  4. cookies().set only works where Next.js can still add a Set-Cookie header: in a Server Action or a Route Handler. Calling it while rendering a page throws, which is why login and logout are actions.
  5. readSession() is the one function the rest of the app uses to answer "is there a valid session?": it returns the verified payload or null, never a half-trusted value.
AdvancedA stand-in user and note store
lib/db.ts
import "server-only";
import { scryptSync, timingSafeEqual } from "node:crypto";

// An in-memory stand-in for a database. A fixed salt keeps the demo short; real code stores a random salt per user.
const hash = (password: string) => scryptSync(password, "demo-salt", 32);

const users = [
  { id: "u1", email: "alice@example.com", name: "Alice", role: "member" as const, passwordHash: hash("alice-pass") },
  { id: "u2", email: "bob@example.com", name: "Bob", role: "admin" as const, passwordHash: hash("bob-pass") },
];

// On globalThis because pages and Route Handlers are bundled separately and would each get their own copy.
const store = globalThis as { demoNotes?: { id: string; ownerId: string; text: string }[] };
export const notes = (store.demoNotes ??= [
  { id: "n1", ownerId: "u1", text: "Alice: buy milk" },
  { id: "n2", ownerId: "u2", text: "Bob: rotate the API keys" },
]);

export function findUserByCredentials(email: string, password: string) {
  const user = users.find((u) => u.email === email);
  const attempt = hash(password);
  return user && timingSafeEqual(user.passwordHash, attempt) ? user : null;
}

export function findUserById(id: string) {
  return users.find((u) => u.id === id) ?? null;
}
AdvancedLogin and logout Server Actions
app/actions/auth.ts
"use server";
import { redirect } from "next/navigation";
import { createSession, deleteSession } from "@/lib/session";
import { findUserByCredentials } from "@/lib/db";

export async function login(formData: FormData) {
  const user = findUserByCredentials(String(formData.get("email")), String(formData.get("password")));
  if (!user) redirect("/login?error=1");
  await createSession(user.id, user.role);
  redirect("/dashboard");
}

export async function logout() {
  await deleteSession();
  redirect("/login");
}
AdvancedThe login page and its walkthrough
app/login/page.tsx
import { Suspense } from "react";
import { login } from "@/app/actions/auth";

export default function LoginPage({ searchParams }: PageProps<"/login">) {
  return (
    <main>
      <h1>Sign in</h1>
      <Suspense>
        <LoginError searchParams={searchParams} />
      </Suspense>
      <form action={login}>
        <input name="email" type="email" placeholder="Email" required />
        <input name="password" type="password" placeholder="Password" required />
        <button>Sign in</button>
      </form>
    </main>
  );
}

async function LoginError({ searchParams }: { searchParams: PageProps<"/login">["searchParams"] }) {
  const { error } = await searchParams;
  return error ? <p role="alert">Wrong email or password.</p> : null;
}
What’s happening
  1. The login page renders a plain <form action={login}>. Because login is a Server Action, Next.js renders the form with a hidden $ACTION_ID_… input, so it works even before JavaScript loads (progressive enhancement); with JavaScript it posts with fetch instead.
  2. login(formData) runs on the server. findUserByCredentials hashes the submitted password with scrypt and compares it with timingSafeEqual, which takes the same time whether the first or the last byte differs, so response timing doesn't leak how close a guess was.
  3. On a wrong password it calls redirect("/login?error=1"). redirect throws a special error that Next.js turns into a redirect response, so nothing after it runs. The page reads searchParams inside its own <Suspense> boundary because, with Cache Components, request data outside a boundary is a build error.
  4. On success it calls createSession(user.id, user.role), which queues the Set-Cookie header, then redirect("/dashboard"). The redirect and the cookie go back in the same response.
  5. logout deletes the cookie and redirects. With a stateless session that's all logout can do: a copy of the old token, if someone stole it, stays valid until expiresAt. That's the trade-off from the table above.
AdvancedWhat the login response contains

Here's what the browser actually receives. I ran next build && next start -p 4509 and posted the form with curl, the same request a browser without JavaScript sends (the action id came from the hidden input in the login page's HTML and changes with every build; date and connection headers trimmed):

curl -sD - -F '$ACTION_ID_<id>=' -F email=alice@example.com -F password=alice-pass localhost:4509/login
HTTP/1.1 303 See Other
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Set-Cookie: session=eyJ1c2VySWQiOiJ1MSIsInJvbGUiOiJtZW1iZXIiLCJleHBpcmVzQXQiOjE3OTM1NTcwOTgxMDB9.RYsWeT-x9Ag4pIqsHQNxCSB05jEtPC3TbFeM2JmA3yg; Path=/; Expires=Sun, 01 Nov 2026 18:18:18 GMT; Secure; HttpOnly; SameSite=lax
Location: /dashboard
What’s happening
  1. The action answered with 303 See Other, the status Next.js uses for a redirect after a POST, so the browser follows it with a GET to /dashboard instead of re-posting the form.
  2. Set-Cookie carries the token with exactly the attributes createSession asked for. With a wrong password the same request came back 303 with Location: /login?error=1 and no Set-Cookie at all.
  3. The part before the dot is only base64url, not encryption. Decoding it with Buffer.from(body, "base64url") printed {"userId":"u1","role":"member","expiresAt":1793557098100}. Anyone holding the cookie can read it; nobody without the secret can change it.
  4. I then edited the decoded body to "role":"admin", re-encoded it and kept the old signature. GET /api/notes with that cookie answered 401 Unauthorized with {"error":"Not signed in"}, and GET /dashboard with it got proxy's 307 to /login. The genuine cookie got 200 and [{"id":"n1","text":"Alice: buy milk"}].
  5. In headless Chrome, after signing in through the real form, document.cookie was "": the session cookie is invisible to page scripts because of HttpOnly, which is what keeps an XSS bug from stealing it.
AdvancedGotcha: signed is not encrypted
AdvancedReading the session with Cache Components

With cacheComponents: true (the default for new 16.x apps), a session read is request-time data: it can't be prerendered into the static shell, so anything that reads it sits behind a <Suspense> boundary and streams in. A few rules from the bundled "Authentication with Cache Components" guide are worth knowing by heart:

  • A plain 'use cache' function can't call cookies() (it throws). Read the session outside, then pass a stable value such as the user id into the cached function, where it becomes part of the cache key.
  • 'use cache: private' can read cookies() and headers(); its result is cached only in the browser, never on the server, which suits "the current user" (see 'use cache: private' and 'use cache: remote').
  • Cache keys and cacheTag values are stored in plain text. Key and tag on a user id, never on a token, email or password.
  • Don't await the session at the top of a layout: it holds the whole segment, including {children}, behind the request. Push the read into a small component inside a boundary (a user menu, a badge).
  • Client Components can't import the DAL. A Server Component reads the user and passes a narrow object (or a Promise for use()) down.
AdvancedDatabase sessions and revocation

Database sessions: the same app with a revocable session

The signed cookie above can't be revoked. To switch to database sessions you change only the session module; the DAL, actions and pages stay the same, which is the point of having one readSession():

  • Login inserts a row { id: randomId, userId, expiresAt } into a sessions table and puts the random id in the cookie. The id must come from a cryptographic generator (crypto.randomUUID() or 32 random bytes), never from an auto-increment counter.
  • readSession() looks the id up and returns null if the row is missing or expired. Wrap the lookup in React's cache() so a page that calls it from several components hits the database once per render.
  • Logout deletes the row, so a stolen copy of the cookie stops working immediately. "Log out of all devices" is DELETE FROM sessions WHERE user_id = ?, and banning a user takes effect on their next request.
  • Proxy can no longer verify the session without a database call, which the docs say to avoid there. The bundled guide suggests encrypting the session id in the cookie anyway, so proxy can at least check that the cookie is well-formed and not expired before the real lookup happens in the DAL.
  • Sliding expiry (extend the session while the user is active) means updating expiresAt and re-setting the cookie, which can only happen in a Server Action or Route Handler, not while rendering a page.
AdvancedWhat an auth library does for you

A library such as Auth.js, Better Auth or Clerk fills the same slots as the hand-written code above, plus the parts that are easy to get wrong: OAuth and OpenID Connect flows (state and PKCE checks, token exchange), account linking, email verification and password reset tokens, multi-factor authentication, rate limiting on the login endpoint, and key rotation. In App Router terms you usually get a server helper that plays the role of readSession(), a helper for proxy, and a way to sign in and out from actions. What no library can do for you is the second half of Authorisation close to the data: deciding whether this user may touch this row. That logic stays in your DAL.

AdvancedSessions before the App Router

#Authorisation close to the data

Added

Authentication tells you who is calling; authorisation decides whether they may read this row or run this mutation. In a classic single-page app there's one gate: the API. A Next.js app has many entry points: every page and layout renders on the server and sends its output as an RSC payload, every Server Action is a public POST endpoint, every Route Handler is an API, and proxy may or may not run in front of any of them depending on its matcher. If the check lives in only one of those places, the others are open.

So the rule from the bundled docs is: verify in the Data Access Layer, close to the data, and call the DAL from every page, Server Action and Route Handler. Proxy can still do a quick optimistic check (is there a validly signed cookie? if not, redirect to /login), but it's a convenience for the user, not the lock. Think of the bank: the guard at the door turns away people without a badge, but the vault has its own lock and checks your badge again, and it also checks that the box you're opening is yours.

Every entry point reaches the data through the DAL Browser proxy.ts optimistic check: signed cookie? no → 307 /login Page / layout render Server Action (POST) Route Handler DAL verifySession() is it theirs? return a DTO the real lock database requests proxy never sees (unmatched paths, a bypass bug)
Proxy filters early; the DAL is the check every path has to pass.

The app from the previous topic, completed: a DAL, a delete action, a shared note list used on two pages, an API route and an optimistic proxy.

lib/dal.ts
import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { readSession } from "./session";
import { findUserById, notes } from "./db";

// The one place that answers "who is calling?". Every read and write below goes through it.
export const verifySession = cache(async () => {
  const session = await readSession();
  if (!session) redirect("/login");
  return session;
});

export async function getCurrentUser() {
  const { userId } = await verifySession();
  const user = findUserById(userId);
  if (!user) redirect("/login");
  return { name: user.name, role: user.role }; // a DTO: no email, no password hash
}

export async function getMyNotes() {
  const { userId } = await verifySession();
  return notes.filter((n) => n.ownerId === userId).map(({ id, text }) => ({ id, text }));
}

export async function deleteNote(noteId: string) {
  const { userId } = await verifySession(); // authentication: who is this?
  const note = notes.find((n) => n.id === noteId);
  if (!note || note.ownerId !== userId) return false; // authorisation: is it theirs?
  notes.splice(notes.indexOf(note), 1);
  return true;
}
What’s happening
  1. verifySession is the gate: it reads and verifies the cookie and, with no valid session, calls redirect("/login"), which throws, so no caller ever continues without a session. It's wrapped in React's cache(), so a page that calls it from three components verifies the cookie once per render. (The docs point out that cache() does nothing in Server Actions and Route Handlers: they aren't a render pass, so every call runs.)
  2. Every exported DAL function starts with await verifySession(). getCurrentUser returns { name, role }, a DTO, not the database row, so the email and password hash can't end up in a Client Component's props. getMyNotes filters by the session's userId, never by an id the caller passes in.
  3. deleteNote(noteId) does both checks: authentication (verifySession) and authorisation (note.ownerId !== userId → refuse). It answers the same way for "doesn't exist" and "belongs to someone else", so an attacker can't probe which ids exist. Without the ownership line this would be an IDOR (insecure direct object reference) bug: any signed-in user could delete anyone's note by changing the hidden noteId.
  4. Nothing here imports React components or reads FormData: the DAL is plain server functions that take ids and return plain objects. Pages, actions and Route Handlers are thin callers, which is what makes "every path goes through the DAL" easy to audit.
AdvancedThe delete action that calls the DAL

The callers: a thin action, a note list shared by two pages, an API route, and an optimistic proxy.

app/actions/notes.ts
"use server";
import { refresh } from "next/cache";
import { deleteNote } from "@/lib/dal";

export async function deleteNoteAction(formData: FormData) {
  const deleted = await deleteNote(String(formData.get("noteId")));
  if (deleted) refresh(); // re-render the dashboard with the new list
}
AdvancedA note list shared by two pages
components/note-list.tsx
import { deleteNoteAction } from "@/app/actions/notes";

// Shared by the dashboard and the home page, so deleteNoteAction is reachable from both routes.
export function NoteList({ notes }: { notes: { id: string; text: string }[] }) {
  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>
          {note.text}
          <form action={deleteNoteAction}>
            <input type="hidden" name="noteId" value={note.id} />
            <button>Delete</button>
          </form>
        </li>
      ))}
    </ul>
  );
}
AdvancedThe dashboard page
app/dashboard/page.tsx
import { Suspense } from "react";
import { getCurrentUser, getMyNotes } from "@/lib/dal";
import { logout } from "@/app/actions/auth";
import { NoteList } from "@/components/note-list";

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading your notes…</p>}>
        <Notes />
      </Suspense>
    </main>
  );
}

async function Notes() {
  const [user, notes] = await Promise.all([getCurrentUser(), getMyNotes()]);
  return (
    <>
      <p>
        Signed in as {user.name} ({user.role})
      </p>
      <NoteList notes={notes} />
      <form action={logout}>
        <button>Sign out</button>
      </form>
    </>
  );
}
AdvancedA public home page proxy never sees
app/page.tsx
import { Suspense } from "react";
import Link from "next/link";
import { readSession } from "@/lib/session";
import { getMyNotes } from "@/lib/dal";
import { NoteList } from "@/components/note-list";

// A public page (proxy doesn't run here) that shows your notes if you happen to be signed in.
export default function HomePage() {
  return (
    <main>
      <h1>Notes</h1>
      <Suspense fallback={null}>
        <RecentNotes />
      </Suspense>
    </main>
  );
}

async function RecentNotes() {
  if (!(await readSession())) return <Link href="/login">Sign in to see your notes</Link>;
  return <NoteList notes={await getMyNotes()} />;
}
AdvancedA Route Handler that answers 401
app/api/notes/route.ts
import { readSession } from "@/lib/session";
import { getMyNotes } from "@/lib/dal";

export async function GET() {
  // An API answers 401, it doesn't redirect, so check the session before calling the DAL.
  if (!(await readSession())) return Response.json({ error: "Not signed in" }, { status: 401 });
  return Response.json(await getMyNotes());
}
AdvancedThe optimistic proxy and its walkthrough
proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { verifySessionToken } from "@/lib/session-token";

// Optimistic check: reads only the cookie (no database), so it's cheap enough to run before every matched request.
export async function proxy(request: NextRequest) {
  const session = await verifySessionToken(request.cookies.get("session")?.value);
  if (!session) return NextResponse.redirect(new URL("/login", request.url));
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*"] };
What’s happening
  1. deleteNoteAction is a thin "use server" wrapper: it passes the form value to the DAL and calls refresh() only if something was deleted, so the page re-renders with the new list. The action trusts nothing from the form except as a request to be checked.
  2. NoteList is used by both /dashboard and the public home page /, so the delete action is reachable through both routes. proxy.ts only matches /dashboard/:path*: it never sees a POST to /. That's deliberate here, and it's exactly the situation the docs warn about ("a refactor that moves a Server Function to a different route can silently remove Proxy coverage").
  3. dashboard/page.tsx and the home page both put the session-dependent part in a <Suspense> boundary, so the heading prerenders into the static shell. The home page uses readSession() (which returns null) rather than verifySession() (which redirects), because signed-out visitors are welcome there.
  4. The Route Handler checks readSession() itself and answers 401 JSON instead of redirecting, because an API client wants a status code, not a login page. Then it uses the same DAL function as the pages.
AdvancedRoute table for the session app
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
├ ƒ /api/notes
├ ◐ /dashboard
└ ◐ /login


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedAttacking the delete action with curl

The pages are ◐: the heading is in the prerendered static shell and the session-dependent parts stream in from their <Suspense> boundaries. The Route Handler reads cookies, so it's ƒ, and ƒ Proxy (Middleware) confirms the proxy file was picked up. Now the attack, run with curl against next start (the delete action's id was copied from the home page's HTML while signed in, exactly as an attacker with their own account could do; noisy headers trimmed):

curl against next start -p 4509
### GET /dashboard with no cookie
HTTP/1.1 307 Temporary Redirect
location: /login

### POST / (delete n2) with no cookie: proxy doesn't run on /
HTTP/1.1 303 See Other
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Location: /login

### POST / (delete n2, Bob's note) as Alice
HTTP/1.1 200 OK
### GET /api/notes as Bob
[{"id":"n2","text":"Bob: rotate the API keys"}]

### POST / (delete n1, her own note) as Alice
HTTP/1.1 200 OK
### GET /api/notes as Alice
[]
What’s happening
  1. GET /dashboard without a cookie never reached the page: proxy matched the path, found no valid token and answered 307 itself. That's the optimistic check doing its job, a fast redirect with a real status code before any rendering.
  2. The POST to / with the delete action's id and no cookie was not seen by proxy (the matcher only covers /dashboard), so the request reached the action. The action called the DAL, verifySession() found no session and threw its redirect, and Next.js answered 303 to /login. Nothing was deleted: the DAL refused, not proxy.
  3. Alice, properly signed in, posted noteId=n2. She passed authentication but failed authorisation: deleteNote found that n2 belongs to u2 and returned false. Bob's list afterwards still contains n2.
  4. When Alice deleted her own n1, the same action succeeded and her list became []. Same endpoint, same code path: the only difference is the ownership check in the DAL.
  5. If the delete logic had trusted proxy (or the page's own check) instead, step 2 would have deleted Bob's note for an anonymous caller. This is what "authorise close to the data" buys you.
Does the layout guard hide the page?

This app has no session library, just a role cookie. The admin layout refuses to render its children unless the cookie says admin. A signed-out visitor requests /admin and sees "Admins only." Does the confidential revenue figure reach their browser?

app/admin/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

export default function AdminLayout({ children }: LayoutProps<"/admin">) {
  return (
    <Suspense fallback={<p>Checking…</p>}>
      <Gate>{children}</Gate>
    </Suspense>
  );
}

// ❌ The "SPA guard": hide the page unless the user is an admin.
async function Gate({ children }: { children: React.ReactNode }) {
  const role = (await cookies()).get("role")?.value; // stands in for a real session check
  if (role !== "admin") return <p>Admins only.</p>;
  return children;
}
app/admin/page.tsx
export default function AdminPage() {
  return <p>Quarterly revenue: $4.2M (confidential)</p>;
}
Show answer

Yes. The visible page says "Admins only.", but the HTML the browser downloads contains the revenue figure.

next build
Route (app)
┌ ○ /_not-found
└ ◐ /admin


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
node leak.mjs (fetches localhost:4509/admin with no cookie, then searches the HTML)
visible <p> tags: [ '<p>Checking…</p>', '<p>Admins only.</p>' ]
scripts containing it: 2 [ 'self.__next_f.push([1,', 'self.__next_f.push([1,' ]
excerpt: en\":[[\"$\",\"p\",null,{\"children\":\"Quarterly revenue: $4.2M (confidential)\"}],null,[
What’s happening
  1. The App Router doesn't render a page inside its layout the way nested React components render. The router renders each segment (layout, page) itself and hands the page to the layout as an already-rendered children element; the page's output goes into the RSC payload as its own piece, which the client-side router slots into the layout.
  2. So when Gate returns <p>Admins only.</p> instead of children, the visible HTML has no revenue paragraph, but the page segment still ran on the server and its output is in the payload. The page is also fully static, so it was rendered at build time and serialised no matter who asks.
  3. The payload is shipped inside the HTML as self.__next_f.push(...) scripts, so anyone can read it with View Source or curl. The searched response above contains the confidential string twice, both inside those scripts.
  4. The fix is to check where the data is produced: in the page itself or, better, in the DAL function the page calls (if (role !== "admin") forbidden() before reading the revenue). The bundled docs say the same: a layout "does not control whether the rest of the route renders", and returning null from a layout is "not recommended" as a guard.
  5. Layout checks have a second problem: layouts don't re-render on client-side navigation between their child pages, so a check there can be stale. Use layouts for presentation (show the user's avatar), and keep the checks next to the data.
AdvancedProxy as an optimistic check

Proxy (proxy.ts, called middleware.ts before Next.js 16; see proxy.ts (formerly middleware)) runs before routing, so it's the cheapest place to bounce signed-out users from whole sections and to return a real 307 before any HTML streams. Use it, but know its limits:

  • Keep it to the cookie. It runs on every matched request, including prefetches, so the docs say to read only the session cookie there and avoid database calls. That makes it optimistic: it can't know about a revoked database session or a deleted user.
  • Matchers have gaps. Server Functions aren't separate routes: they're POSTs to whatever page uses them, so a matcher that skips that page also skips the action (step 2 above).
  • It has been bypassed for real. In March 2025, CVE-2025-29927 let anyone skip middleware entirely with one spoofed header (details in the Security checklist); in July 2026, CVE-2026-64642 was another middleware/proxy bypass for a specific Turbopack and i18n setup. Apps that also checked in the DAL were not exposed by either.
  • It's still useful for static pages. A DAL check runs at request time; a fully static page (○) was rendered at build time and served as a file. If static content must be gated (a paywalled article), proxy is the only place a per-request check can run before the file is sent.
  • Runtime. In 16, proxy always runs on Node.js and runtime can't be configured there; middleware.ts before 16 ran on the Edge runtime by default (Node.js became stable for middleware in 15.5).
AdvancedRoles and permissions in the DAL

The DAL above checks ownership. Real apps also have roles (member, admin, billing manager) and per-resource sharing. Keep both in the same place:

  • Put the rule in one function per resource, such as canEditProject(user, project), and call it from the DAL function that loads or changes the project. A UI that hides the "Edit" button calls the same function, so the button and the endpoint can never disagree.
  • Read roles from the source of truth. A role inside a stateless cookie is a snapshot from login time: demote an admin and their cookie still says admin until it expires. For anything sensitive, load the current role from the database in verifySession() (cached per render) or keep sessions short.
  • Scope queries, don't filter afterwards. WHERE owner_id = $userId in the query is safer than loading all rows and filtering in JavaScript: a forgotten filter then returns nothing instead of everything.
  • Fail closed. If the rule can't decide (missing data, unknown role), refuse. deleteNote above returns "not found" unless the note exists and belongs to the caller.

#forbidden(), unauthorized() and error statuses

Added

HTTP has two different "no" answers for auth, and mixing them up is a classic interview slip. 401 Unauthorized really means unauthenticated: "I don't know who you are, sign in". 403 Forbidden means "I know who you are, and you may not". A third option, 404, is sometimes the right answer for a forbidden resource too, when admitting that it exists would leak information (GitHub answers 404 for private repositories you can't see).

Next.js has had notFound() and not-found.tsx since 13. Next.js 15.1 added the matching pair for auth: unauthorized() and forbidden() from next/navigation, with unauthorized.tsx and forbidden.tsx files for the UI. They're still experimental in 16.4.3, behind experimental.authInterrupts. They work like notFound(): they throw, rendering stops at that point, and Next.js renders the nearest matching file instead, adds <meta name="robots" content="noindex">, and sets the status code, if the status hasn't already been sent. That last part is the one to understand.

The status code goes out with the first byte of the response. Once Next.js has started streaming (a Suspense fallback rendered, or a static shell was sent), the status is fixed at 200; a later forbidden() can still swap in the 403 UI and the noindex tag, but it can't change the status. With Cache Components almost every page streams, so this shows up immediately:

app/reports/page.tsx
import { Suspense } from "react";
import { forbidden, unauthorized } from "next/navigation";
import { getViewer } from "@/lib/viewer";
import { exportReport } from "./actions";

export default function ReportsPage() {
  return (
    <main>
      <h1>Reports</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Revenue />
      </Suspense>
    </main>
  );
}

async function Revenue() {
  const viewer = await getViewer();
  if (!viewer) unauthorized(); // 401 page
  if (viewer.role !== "admin") forbidden(); // 403 page
  return (
    <>
      <p>Quarterly revenue: $4.2M</p>
      <form action={exportReport}>
        <button>Export CSV</button>
      </form>
    </>
  );
}
What’s happening
  1. /reports follows the pattern the docs recommend: a static heading, and the check inside Revenue, behind <Suspense>, so the shell can prerender and only the guarded part waits for the request.
  2. A signed-out viewer makes getViewer() return null, so unauthorized() throws and the rest of Revenue never runs. A member gets past that line and hits forbidden(). Both have the TypeScript return type never, which is why viewer.role type-checks after the first if: TypeScript knows viewer can't be null there.
  3. Neither call needs return: they throw a special error (NEXT_HTTP_ERROR_FALLBACK;401 or ;403) that Next.js catches and replaces with the nearest unauthorized.tsx or forbidden.tsx, adding a noindex meta tag.
  4. Only an admin reaches the revenue and the "Export CSV" form. The action behind that form is a separate endpoint and does its own check, below.
AdvancedEnabling authInterrupts

The rest of the app: the config flag, a stand-in for the DAL, the two fallback files, the action, and three more routes that make the checks at different moments.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    authInterrupts: true, // enables forbidden() and unauthorized()
  },
};

export default nextConfig;
AdvancedA stand-in for verifySession
lib/viewer.ts
import "server-only";
import { cookies } from "next/headers";

// Stands in for verifySession() from the DAL: null when signed out.
export async function getViewer() {
  const role = (await cookies()).get("role")?.value;
  return role === "admin" || role === "member" ? { role } : null;
}
AdvancedThe unauthorized.tsx fallback
app/unauthorized.tsx
export default function Unauthorized() {
  return <h1>401: please sign in</h1>;
}
AdvancedThe forbidden.tsx fallback
app/forbidden.tsx
export default function Forbidden() {
  return <h1>403: you don't have access</h1>;
}
AdvancedAn action that checks the role itself
app/reports/actions.ts
"use server";
import { forbidden, unauthorized } from "next/navigation";
import { getViewer } from "@/lib/viewer";

export async function exportReport() {
  const viewer = await getViewer(); // the page's check doesn't protect this endpoint
  if (!viewer) unauthorized();
  if (viewer.role !== "admin") forbidden();
  console.log("export started by", viewer.role);
}
AdvancedA blocking page with a top-level check
app/billing/page.tsx
import { forbidden, unauthorized } from "next/navigation";
import { getViewer } from "@/lib/viewer";

export const instant = false; // allow a blocking route: render the whole page before sending anything

export default async function BillingPage() {
  const viewer = await getViewer(); // top level: no Suspense boundary above it
  if (!viewer) unauthorized();
  if (viewer.role !== "admin") forbidden();
  return <p>Invoices: 12 unpaid</p>;
}
AdvancedA page refused at build time
app/archive/page.tsx
import { unauthorized } from "next/navigation";

// A section closed to everyone: no request data, so the check runs at build time.
export default function ArchivePage() {
  unauthorized();
}
AdvancedInterrupts in a Route Handler
app/api/report/route.ts
import { forbidden, unauthorized } from "next/navigation";
import { getViewer } from "@/lib/viewer";

export async function GET() {
  const viewer = await getViewer();
  if (!viewer) unauthorized();
  if (viewer.role !== "admin") forbidden();
  return Response.json({ revenue: 4_200_000 });
}
What’s happening
  1. experimental.authInterrupts: true switches the two functions on. Without it, calling them is an error; the option name and the "experimental" label are both from the 16.4.3 docs.
  2. /billing does the check at the top of the page, outside any boundary. Under Cache Components that's request data outside <Suspense>, which fails the build (error below), so it opts out with export const instant = false, the "[block]" fix the error message suggests.
  3. /archive calls unauthorized() with no request data at all, so the check happens while the page is prerendered at build time.
  4. The Route Handler calls the same functions. There's no page to render, so they simply become the response status; the action does the same, re-checking the role itself because the page's check doesn't protect the action's endpoint.
AdvancedRoute table for the interrupts app
next build
Route (app)
┌ ○ /_not-found
├ ƒ /api/report
├ ○ /archive
├ ƒ /billing
└ ◐ /reports


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedObserved statuses for each role

What came back, with next start and curl for the status, and headless Chrome for what the user ends up seeing (the role cookie set to nothing, member and admin):

RouteSigned outMemberAdmin
/reports (◐, check inside Suspense)200, page shows "401: please sign in", noindex200, shows "403: you don't have access", noindex200, revenue
/billing (ƒ, instant = false)200, shows "401: please sign in", noindex200, shows "403…", noindex200, invoices
/archive (○, check at build time)401401401
/api/report (Route Handler)401, empty body403, empty body200, {"revenue":4200000}
What’s happening
  1. /reports signed out: the response was 200. The static shell (<h1>Reports</h1> and the Loading… fallback) had already gone out with that status before Revenue ran, so unauthorized() could only stream the 401 UI and the noindex tag. In Chrome the whole page became "401: please sign in": the root unauthorized.tsx replaced everything under the root layout, heading included.
  2. /billing is ƒ and blocks, so I expected a real 401. It was 200 too: the response headers included x-nextjs-postponed: 1, and the HTML started with an error shell (<html id="__next_error__">) that the browser then filled with the 401 UI. The docs say "calling unauthorized() before streaming starts returns a 401", but in this Cache Components app even a blocking ƒ page was served as a postponed shell, so streaming had already started and a check made at request time inside a page did not change the status. Rebuilt with partialPrefetching: false, /billing and /reports still answered 200 with x-nextjs-postponed: 1.
  3. /archive was the only page with a real 401, for every visitor: the throw happened during the build, before anything streamed, and the page stayed ○ Static. That's the case the docs describe as "a page that reaches unauthorized() during prerendering can remain ○ Static and serve a 401".
  4. The Route Handler returned real 401/403 statuses. An API is where status codes matter most (clients branch on them) and where these functions behave exactly like return new Response(null, { status: 401 }).
  5. In none of the refused cases did the revenue or invoice text appear anywhere in the HTML, payload included. Compare with the layout guard in the previous topic's quiz: checking where the data is produced is what keeps it out of the response.
AdvancedBuild error: runtime data outside Suspense

The build error for a request-time check at the top of a page, before I added instant = false (from next build):

next build (without export const instant = false)
Error: Route "/billing": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
AdvancedGotcha: a 401 page that says 200
Advancedforbidden() and unauthorized() in Server Actions

In Server Actions

The browser calls an action with fetch: I captured the request in headless Chrome when an admin clicked "Export CSV", and it was a POST /reports with a Next-Action: <id> header and the arguments as the body ([] here). Sending the same request with curl as other users:

curl -sD - -H 'Next-Action: <id>' --data '[]' localhost:4509/reports
signed out → HTTP/1.1 401 Unauthorized   Content-Type: text/x-component   …E{"digest":"NEXT_HTTP_ERROR_FALLBACK;401"}
member     → HTTP/1.1 403 Forbidden      Content-Type: text/x-component   …E{"digest":"NEXT_HTTP_ERROR_FALLBACK;403"}
admin      → HTTP/1.1 200 OK (server log: "export started by admin")
What’s happening
  1. The action re-read the cookie itself. Even though the member never saw the button (the page refused to render it), the endpoint exists, and calling it directly is one curl command.
  2. forbidden() inside the action produced a real 403: an action response is a fresh response, not a stream that already started. The body is an RSC payload whose error row carries the digest NEXT_HTTP_ERROR_FALLBACK;403, which the client router turns into the forbidden.tsx UI.
  3. The digest string is how Next.js recognises its own control-flow errors. It's also why a try/catch around forbidden() breaks it: you'd catch the interrupt. The docs say to rethrow with unstable_rethrow(error) if you must catch.
  4. One rough edge I hit: posting the same action as a no-JavaScript form ($ACTION_ID_… field) as a member came back 404, and the server logged "Couldn't find all resumable slots by key/index during replaying". The interrupt itself worked (nothing was exported), but don't build anything on the status of that path.
AdvancedChoosing redirect, 401, 403 or 404

Choosing between redirect, unauthorized, forbidden and notFound

  • Signed out, page meant for users: redirect("/login") (often with a ?next= return path) is still the most common choice; unauthorized() suits apps that render a sign-in form in unauthorized.tsx instead of moving the user.
  • Signed in, not allowed: forbidden(), unless the resource's existence is itself secret: then notFound(), so a stranger can't tell "exists but not yours" from "doesn't exist" (the DAL's deleteNote in the previous topic does the same thing for actions).
  • APIs: real statuses from Route Handlers; JSON clients shouldn't receive redirects to an HTML login page.
  • Never in the root layout: both functions can't be called there (the docs say so for each), and a root layout check would be a layout guard anyway.
  • Always awaited: they work by throwing. A call inside a promise nobody awaits throws where nothing catches it; the dev server logs ⨯ unhandledRejection: NEXT_HTTP_ERROR_FALLBACK;401 and no fallback renders.

#Environment variables and secrets

Added

Next.js loads .env files into process.env on the server, and that's where secrets belong: database URLs, API keys, the session secret. The one twist is the NEXT_PUBLIC_ prefix: at next build, every process.env.NEXT_PUBLIC_SOMETHING in your code is replaced with the literal value, as text, inside the JavaScript sent to browsers. So there are two kinds of variable with two different lifetimes. Server variables are read at runtime from the environment of the process. Public variables are baked in at build time and never change afterwards, whatever the deployment environment says.

The mental model is a printed brochure versus a phone call. A NEXT_PUBLIC_ value is printed into the brochure (the bundle) at the print shop (next build): every copy says the same thing, and changing your number later doesn't reprint them. A server variable is a phone call the server makes when it needs the answer, so it gets the current one. The trap in the App Router is that "server" includes more than you think: Client Components also run on the server (to render HTML, and at build time to prerender the static shell), and in that run they can read your secrets.

Files, in the order Next.js looks for a variable (first match wins): process.env itself, then .env.$(NODE_ENV).local, .env.local (skipped when NODE_ENV is test), .env.$(NODE_ENV), .env. They live in the project root, not in src/. create-next-app git-ignores .env* files; the usual convention is defaults in .env and secrets in .env.local or in the host's settings. NODE_ENV is development for next dev and production for everything else.

app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { ClientInfo } from "./client-info";

export default function Page() {
  return (
    <main>
      <p>Static part: {process.env.GREETING}</p>
      <Suspense fallback={<p>…</p>}>
        <RuntimePart />
      </Suspense>
      <ClientInfo />
    </main>
  );
}

async function RuntimePart() {
  await connection(); // wait for a request: everything below runs per request
  return <p>Runtime part: {process.env.GREETING}</p>;
}
.env
GREETING=hello-from-dotenv
DB_PASSWORD=dotenv-secret
NEXT_PUBLIC_API_URL=https://api.build.example
app/client-info.tsx
"use client";

export function ClientInfo() {
  return (
    <>
      <p>Client sees API URL: {process.env.NEXT_PUBLIC_API_URL}</p>
      <p>Client sees DB password: {String(process.env.DB_PASSWORD)}</p>
    </>
  );
}
What’s happening
  1. .env defines three variables. Only NEXT_PUBLIC_API_URL has the prefix, so it's the only one Next.js will inline into browser JavaScript; GREETING and DB_PASSWORD are server-only in the sense that they never appear in a client bundle.
  2. The first paragraph of page.tsx reads GREETING with no request API before it, so it belongs to the static shell: it runs during next build, with whatever the build machine's environment says.
  3. RuntimePart calls await connection() first. That tells Next.js to wait for a real request, so its read of GREETING happens in the running server, per request, and it needs the <Suspense> boundary around it.
  4. ClientInfo is a Client Component, but it isn't only a browser component: Next.js also renders it on the server to produce HTML, and here that server render happens at build time, in the static shell. That's where process.env.DB_PASSWORD still has a value.
  5. Predict before reading on: if the build saw .env and the server is started with different values, what does each line say in the HTML, and what does it say after the browser hydrates?
AdvancedRoute table for the env app
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedBuild values versus runtime values

I built it with only the .env file, then started it with different values in the real environment, as you would when one Docker image is promoted from staging to production: GREETING=hello-from-runtime DB_PASSWORD=runtime-secret NEXT_PUBLIC_API_URL=https://api.runtime.example next start -p 4509. Then I looked at the HTML with curl and at the page after hydration in headless Chrome:

curl -s localhost:4509/ (the <p> tags in document order, React's <!-- --> separators removed)
<p>Static part: hello-from-dotenv</p>
<p>…</p>
<p>Client sees API URL: https://api.build.example</p>
<p>Client sees DB password: dotenv-secret</p>
<p>Runtime part: hello-from-runtime</p>
AdvancedWhat the browser shows after hydration
headless Chrome, after hydration
Static part: hello-from-runtime
Runtime part: hello-from-runtime
Client sees API URL: https://api.build.example
Client sees DB password: undefined
exception: Error: Minified React error #418; visit https://react.dev/errors/418?args[]=text&args[]= for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
What’s happening
  1. Static part, HTML: hello-from-dotenv. This paragraph is in the static shell, rendered once during next build, when GREETING came from .env. Changing the variable at runtime didn't change that HTML: it's a file now.
  2. Runtime part: hello-from-runtime. await connection() deferred RuntimePart to request time, so it read the real environment of the running process. This is the documented way to read runtime configuration in the App Router.
  3. API URL: https://api.build.example in the HTML and after hydration, even though the process had https://api.runtime.example. NEXT_PUBLIC_API_URL was inlined into the client bundle at build time: grepping .next/static found api.build.example in one chunk and the name NEXT_PUBLIC_API_URL in none.
  4. DB password, HTML: dotenv-secret. This is the leak. ClientInfo is a Client Component, but its HTML was prerendered on the server during the build, where process.env.DB_PASSWORD exists. The secret is now in a static HTML file served to everyone. In the browser the variable isn't inlined (it has no NEXT_PUBLIC_ prefix), so it's undefined.
  5. Hydration: React found server text that didn't match the client (dotenv-secret vs undefined, and also, interestingly, the static paragraph: the inline RSC payload was rendered at request time and said hello-from-runtime while the shell HTML said hello-from-dotenv), threw error #418 (React's "hydration failed because the server rendered text didn't match the client") and re-rendered on the client. I also ran it with no runtime overrides: the static part then matched, but the DB password line still leaked into the HTML and still caused #418.
  6. What this demonstrates: NEXT_PUBLIC_ values are frozen at build; server values are only "runtime" if they're read at request time; and a secret is only safe if no code that renders on the server for the client reads it. That's what server-only is for.
AdvancedGuarding secrets with server-only

The fix for step 4 is to keep secrets in modules that can't be imported by client code. Mark the module with import "server-only" (Next.js handles the import itself; installing the npm package is optional). This fragment is the mistake, not a working app: a Client Component importing a server-only module. next build refuses it (Turbopack's output; paths shortened, code frames cut):

lib/secrets.ts
import "server-only";

export function getDbPassword() {
  return process.env.DB_PASSWORD;
}
AdvancedThe server-only build error
next build
Error: Turbopack build failed with 2 errors:
./src/lib/secrets.ts:1:1
Error: You're importing a module that depends on "server-only". This API is only available in Server Components in the App Router, but you are using it in the Pages Router.
…
./src/lib/secrets.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module

It should only be used from a Server Component.

Import traces:
  Client Component Browser:
    ./src/lib/secrets.ts [Client Component Browser]
    ./src/app/page.tsx [Client Component Browser]
AdvancedNote on the error wording

(The first message mentions the Pages Router although this is an App Router page; it's Turbopack's generic wording for "not a Server Component". The second one names the real problem.)

AdvancedBuild time, runtime and the Docker image
  • NEXT_PUBLIC_ is per build. One image promoted through staging and production carries the staging values in its JavaScript. The docs' advice: if the browser needs a runtime value, serve it from your own endpoint, or read it on the server and pass it down as a prop.
  • Only literal references are inlined. process.env.NEXT_PUBLIC_X is replaced; process.env[name] and const env = process.env; env.NEXT_PUBLIC_X are not.
  • Server values in the static shell are build values. Anything rendered into a ○ page or a ◐ shell, or inside 'use cache', uses the value the build saw until it's revalidated. Read deployment-specific config after connection() (or another request API) if it must change per environment.
  • next.config.js env: {} is a legacy option: whatever you put there is always inlined into the bundle, prefix or not. Don't put secrets in it.
  • serverRuntimeConfig and publicRuntimeConfig (Pages Router, getConfig()) were removed in Next.js 16; the upgrade guide says to use environment variables instead.
  • Taint as a backstop. experimental.taint enables React's experimental_taintObjectReference and experimental_taintUniqueValue, which make React throw if a marked object or value is passed to a Client Component, and taints process.env itself so it can't be passed whole. It tracks references only: a copy or a derived string isn't protected, so it's a seatbelt, not a design.

#Content Security Policy with nonces

Added

A Content Security Policy is a response header that tells the browser which scripts, styles, images and frames the page may use. Its main job is damage control for XSS: if an attacker manages to inject <script>steal()</script> into your HTML, a strict policy means the browser refuses to run it. The strongest practical policy doesn't list domains at all. It says "run only scripts that carry this nonce", a random value that changes on every response, so an attacker who injects markup can't know it.

The mental model is a guest list with a password of the day. Every legitimate <script> tag gets the day's password (nonce="…") written on it, the doorman (the browser) reads the password from the CSP header, and anything without it is turned away. 'strict-dynamic' adds "and anyone a trusted guest brings in is trusted too", so Next.js's runtime can load more chunks without each needing its own nonce. The catch is in "of the day": the password must be different for every response, so it has to be invented per request and stamped into HTML rendered after it was invented. HTML built at next build time can't carry it.

In Next.js the flow goes through proxy and the request headers:

One nonce per request, from proxy to every script tag Browser proxy.ts Next.js render GET /dynamic nonce N = random request headers: CSP 'nonce-N', x-nonce: N every <script nonce="N"> response: CSP header + nonced scripts runs only scripts with N A shell prerendered at next build was made before N existed: its scripts have no nonce and are blocked.
The request header carries the nonce to the renderer; the response header tells the browser what to accept.
proxy.ts
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); // new for every request
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "font-src 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  // The request header is how Next.js learns the nonce while rendering; the response header is what the browser enforces.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
What’s happening
  1. For every matched request, proxy makes a nonce (crypto.randomUUID(), base64-encoded) and builds the policy: scripts only from this origin and carrying the nonce, with 'strict-dynamic' so scripts loaded by a trusted script are trusted too; object-src 'none', base-uri 'self' and frame-ancestors 'none' close the other classic holes (plugins, <base> hijacking, clickjacking).
  2. It sets the policy twice. On the request (NextResponse.next({ request: { headers } })) so that Next.js, while rendering, can find 'nonce-…' in the Content-Security-Policy request header and stamp it on every script it emits; x-nonce is a copy for your own code, read with (await headers()).get("x-nonce") to pass to <Script nonce> or third-party components. On the response so the browser enforces it.
  3. Note the difference between NextResponse.next({ request: { headers } }), which forwards headers to the app, and NextResponse.next({ headers }), which would send them to the client. The docs call this out, and passing incoming request headers straight into NextResponse.next() was the trigger for CVE-2025-57822 (see the checklist).
  4. The matcher skips static files and, using missing, skips prefetch requests (next-router-prefetch, purpose: prefetch): those aren't HTML documents, so they don't need a policy, and the docs recommend excluding them.
AdvancedA counter that proves hydration

Three pages to test it: a client counter that only works if scripts run, a Partial Prerender page, and a page rendered entirely per request.

app/counter.tsx
"use client";
import { useState } from "react";

// Only works if the browser was allowed to run Next.js's scripts.
export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>;
}
AdvancedA Partial Prerender page under CSP
app/shell/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { Counter } from "../counter";

// A Partial Prerender route: the shell (and its <script> tags) is built at build time.
export default function ShellPage() {
  return (
    <main>
      <h1>Static shell</h1>
      <Counter />
      <Suspense fallback={<p>…</p>}>
        <RequestTime />
      </Suspense>
    </main>
  );
}

async function RequestTime() {
  await connection();
  return <p>Streamed at request time</p>;
}
AdvancedA per-request page and the walkthrough
app/dynamic/page.tsx
import { connection } from "next/server";
import { Counter } from "../counter";

export const instant = false; // allow this route to block: nothing is prerendered into a shell

export default async function DynamicPage() {
  await connection(); // the whole page renders per request, after proxy has made the nonce
  return (
    <main>
      <h1>Rendered per request</h1>
      <Counter />
    </main>
  );
}
What’s happening
  1. Counter is a Client Component with state. Its HTML renders on the server, but the click handler only exists once React has loaded in the browser, so "Clicked 1 times" after a click proves the scripts ran.
  2. /shell is a normal Cache Components page: heading and Counter in the static shell, one dynamic hole. Its shell, including the <script> tags that load React and Next.js, was written during next build.
  3. /dynamic opts out of prerendering: export const instant = false allows a blocking route, and await connection() at the top makes the whole page render per request, after proxy has invented the nonce. Counter is there to prove hydration: it only counts if the browser ran Next.js's scripts.
AdvancedRoute table for the CSP app
next build
Route (app)
┌ ○ /_not-found
├ ƒ /dynamic
└ ◐ /shell


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand
AdvancedNonces observed in curl and Chrome

Results from next start: curl for the headers and the <script> tags, and headless Chrome to click the button and collect the console:

curl + headless Chrome against next start -p 4509
GET /dynamic   content-security-policy: … script-src 'self' 'nonce-NzcyMGU2NTMtM2VkOS00NDUxLTg5NjUtN2QyNmU3YjhkNTU5' 'strict-dynamic'; …
               16 <script> tags, 16 with a nonce
GET /dynamic   content-security-policy: … script-src 'self' 'nonce-NzYxYmVmNzgtOGM5NC00Nzk3LTgyNTctYmNiOTRhZjMzNTU3' 'strict-dynamic'; …
               16 <script> tags, 16 with a nonce
GET /shell     content-security-policy: … script-src 'self' 'nonce-MTNmMjkwZDItMzg4MS00YTgyLWFmMmEtNzM2MmI1MzcyMGNm' 'strict-dynamic'; …
               20 <script> tags, 11 with a nonce

Chrome /dynamic: "Clicked 0 times" → click → "Clicked 1 times", 0 CSP errors
Chrome /shell:   "Clicked 0 times" → click → "Clicked 0 times", 9 CSP errors, the first:
  Loading the script 'http://localhost:4509/_next/static/chunks/0n3kn-p-fj5l5.js' violates the following Content Security Policy directive: "script-src 'self' 'nonce-ZTE3ZDQzNDQtZjQ3Yy00NTc3LTk5ZWItNzYzMDAwNzQ5ZDlj' 'stri…
What’s happening
  1. Two requests to /dynamic got two different nonces, and every one of the 16 script tags in each response carried that response's nonce. Next.js did this itself; no component passed a nonce anywhere.
  2. In Chrome, /dynamic hydrated: the counter went from 0 to 1, and the console had no CSP errors. That's the goal: a strict policy and a working app.
  3. /shell got a fresh nonce in its header too, but 9 of its 20 script tags had none. They're the shell's tags, from the build; the 11 with a nonce were written at request time, along with the streamed hole.
  4. Chrome refused the shell's script chunks ("Loading the script … violates the following Content Security Policy directive"), React never started, and the button stayed at 0. The streamed paragraph still appeared, because the small inline script that swaps it in came with the request-time part and carried the nonce.
  5. What this demonstrates: nonces need per-request HTML. The docs say it plainly: with nonces "all pages must be dynamically rendered", static optimisation and ISR are disabled, and Partial Prerendering is "incompatible with nonce-based CSP since static shell scripts won't have access to the nonce".
AdvancedChoosing a CSP strategy
Nonce CSP from proxyStatic CSP in next.config headers()Hash-based CSP with SRI (experimental)
Inline scriptsAllowed only with the nonceNeeds 'unsafe-inline' (weak against XSS)Not covered: hashes are for script files
Static pages, PPR, CDN cachingLost: every page renders per requestKeptKept
CostServer render on every requestNoneBuild-time hashes
Setupproxy.ts as aboveasync headers() returning the policyexperimental.sri: { algorithm: 'sha256' } (App Router only)
  • SRI still needs 'unsafe-inline' for scripts. I rebuilt this app with experimental.sri and no proxy, and a static policy from next.config headers() with script-src 'self', as in the docs' SRI example. The script files got integrity attributes, but Chrome blocked Next.js's inline scripts and neither /shell nor /dynamic hydrated (the counter stayed at 0). With script-src 'self' 'unsafe-inline' both hydrated. So SRI keeps static rendering, but not a policy free of 'unsafe-inline'.
  • Development needs 'unsafe-eval'. React uses eval in development to rebuild server error stacks in the browser; the docs add it only when NODE_ENV === "development". Production needs neither eval nor 'unsafe-inline' for Next.js itself.
  • Third-party scripts need their domains in the policy, and the nonce passed explicitly: <Script nonce={nonce}>, or <GoogleTagManager nonce={nonce}> from @next/third-parties, with nonce read from headers().
  • Inline styles are the usual source of violations after scripts: CSS-in-JS libraries must support nonces, or the policy needs 'unsafe-inline' for styles.
  • Start in report-only mode. Content-Security-Policy-Report-Only with the same value reports violations without blocking, so you can find what breaks before you enforce it.

#Security checklist for a Next.js app

Added

Most Next.js security work is ordinary web security (validate input, escape output, least privilege), plus a handful of framework-specific places where the "it's just React" intuition is wrong: actions are public endpoints, Client Components render on the server, proxy is not a lock, and the image optimiser and the RSC protocol are network-facing parsers. Interviewers ask about the real incidents because each one teaches one of those lessons. The best answer is a checklist you can reason about, not a list of CVE numbers to recite.

Start with the response headers. These ones don't depend on per-request data, so they can be set statically in next.config without giving up static rendering (a nonce CSP from the previous topic would replace the Content-Security-Policy here):

next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
  { key: "Content-Security-Policy", value: "frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'" },
];

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  poweredByHeader: false, // drop "X-Powered-By: Next.js"
  async headers() {
    return [{ source: "/:path*", headers: securityHeaders }];
  },
};

export default nextConfig;
What’s happening
  1. headers() returns rules shaped like redirects: a source pattern and the headers to add. "/:path*" matches every path, pages and static files alike. These rules run first in the request pipeline, before proxy.
  2. Strict-Transport-Security tells browsers to use HTTPS only for two years (63072000 seconds; it's ignored over plain HTTP); X-Content-Type-Options: nosniff stops the browser guessing that an upload is a script; Referrer-Policy keeps full URLs (which may contain tokens) from leaking to other sites; Permissions-Policy switches off device APIs the app doesn't use.
  3. The CSP here is the part that needs no nonce: frame-ancestors 'none' prevents clickjacking (the modern X-Frame-Options: DENY), and object-src, base-uri and form-action close three classic injection routes. Because nothing depends on the request, none of this forces dynamic rendering.
  4. poweredByHeader: false drops the X-Powered-By: Next.js header Next.js adds by default.
AdvancedA static page to test the headers

A one-line page to check the result against next start:

app/page.tsx
export default function Home() {
  return <h1>Hello</h1>;
}
AdvancedRoute table for the headers app
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content
AdvancedThe headers in a real response
curl -sD - -o /dev/null localhost:4509/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-prerender: 1
link: </_next/static/media/07a39a8a4660cc7c-s.p.0r6juujl39pe6.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2", </_next/static/media/605ce01b664af129-s.p.0wgildi0cnwt9.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
x-nextjs-stale-time: 300
Cache-Control: s-maxage=31536000
ETag: "2wtxn24tpv4r9"
Content-Type: text/html; charset=utf-8
Content-Length: 6165
Date: Sun, 25 Oct 2026 18:13:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5
What’s happening
  1. All five headers came back on the page, and the page is still ○ Static, served from the prerender cache (x-nextjs-cache: HIT): static headers cost nothing.
  2. There's no X-Powered-By: Next.js header: poweredByHeader: false removed it. On the auth app above, which kept the default, a page response did include X-Powered-By: Next.js. Hiding it is minor (fingerprinting is easy anyway), but it's a common checklist item.
  3. The same headers came back on a /_next/static/chunks/…js file, next to its Cache-Control: public, max-age=31536000, immutable: /:path* covers static assets too.
AdvancedNext.js security incidents, 2024 to 2026

The incidents interviewers mention

Every entry below was checked against the official advisory or the Next.js blog in October 2026. Version numbers are the first patched releases on each line; the template this site uses (16.4.3) postdates all of them.

WhenIssueWhat happenedLesson
May 2024CVE-2024-34351, SSRF in Server Actions (13.4 to 14.1.0, fixed in 14.1.1)On self-hosted apps, a Server Action that redirected to a relative path could be made to fetch from a server of the attacker's choosing with a modified Host headerTreat Host and X-Forwarded-* as attacker input; put a proxy in front that sets them
March 2025CVE-2025-29927, middleware bypass, CVSS 9.1 (11.1.4 to 15.2.2; fixed in 12.3.5, 13.5.9, 14.2.25, 15.2.3)Next.js used an internal x-middleware-subrequest header to avoid running middleware twice; a client could send it and skip middleware entirely. Self-hosted next start and standalone apps were exposed; Vercel, Netlify and Cloudflare Workers were notMiddleware/proxy is not an auth boundary. Workaround for old versions: strip that header at the edge
August 2025CVE-2025-57822, SSRF via middleware (fixed in 14.2.32, 15.4.7)Passing incoming request headers straight into NextResponse.next() could let sensitive headers be reflected, enabling SSRF on self-hosted appsKnow what you hand to NextResponse.next(): { request: { headers } } goes to your app, { headers } goes to the client; and patch
December 2025"React2Shell": CVE-2025-55182 (React) / CVE-2025-66478 (Next.js), CVSS 10.0 (15.x, 16.x and 14.3 canaries; fixed in 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7)A crafted request to the RSC protocol, the deserialiser behind Server Functions, could run code on the server, unauthenticated. App Router only; Pages Router, the Edge runtime, 13.x and 14.x stable unaffected. (cve.org later rejected CVE-2025-66478 as a duplicate of CVE-2025-55182; the Next.js post still uses it)The RSC endpoint is a network parser: patch fast. Next.js advised rotating every secret on apps that were online and unpatched
December 2025CVE-2025-55184 and CVE-2025-55183, then CVE-2025-67779 (fixed in 14.2.35, 15.0.7 … 15.5.9, 16.0.10)Found while studying the React2Shell patch: a request that hangs the server (DoS), and one that returns the compiled source of Server Functions. The first DoS fix was incomplete and was redone as CVE-2025-67779Don't hard-code secrets in action code (they'd be in that source); "upgrade again" happens
July 2026July 2026 security release (fixed in 15.5.21, 16.2.11)Nine issues, including CVE-2026-64642 (proxy bypass with Turbopack and a single i18n locale, 16.x only), CVE-2026-64649 (SSRF in Server Actions on custom servers via Host headers) and CVE-2026-64643 (Server Action and use cache ids disclosed globally)The same three lessons again: proxy isn't the lock, Host headers are input, action ids aren't secrets
August 2026August 2026 security release (fixed in 15.5.24, 16.3.3)RCE in image optimisation of attacker-supplied AVIF files (a libheif bug inside sharp; AVIF optimisation disabled until fixed upstream), and CVE-2026-75604, RCE on Windows-hosted serversThe image optimiser processes untrusted files on your server
September 2026CVE-2026-94483, SSRF in Image Optimization (16.x only, fixed in 16.3.8; the same release shipped 15.5.27 for other issues)An allow-listed remote image URL could lead to requests to private IP ranges; apps with no images.remotePatterns were not affectedKeep remotePatterns as narrow as possible
October 2026October 14 update, out of band (fixed in 15.5.28, 16.4.1)Unauthenticated RCE when a 'use cache' function returns multiple typed arrays (GHSA-673q-mjm6-r5xr, 16.0 to 16.4.0 only); RCE in image optimisation of SVGs on glibc Linux (CVE-2026-96889 / GHSA-2464-fhr4-w9xw, a memory bug in an upstream library, librsvg according to the CVE record; workaround: sharp 0.35.5 or later); a Server Actions DoS (GHSA-p3m5-59mp-jv5r, since assigned CVE-2026-94482)Cache Components and actions are new, network-reachable machinery; stay on a patched line

Two process changes matter in interviews: since July 2026, Next.js pre-announces its security releases (the scheduled July, August and September releases and the out-of-band October update were each announced 5 to 7 days ahead) and ships them for the Active LTS (16.x) and Maintenance LTS (15.5) lines; and for older lines the answer is "upgrade", because the advisories rarely offer workarounds. (The template's sharp is 0.35.5, the version the October advisory names.)

AdvancedXSS, CSRF and SSRF in Next.js terms

Three attack classes come up in every web security interview. Here's what the framework does by default and what's left to you.

React escapes text it renders, and React 19 refuses javascript: URLs. This is plain Node in the template project (react-dom/server 19.3, the same renderer Next.js uses on the server):

react-escaping.cjs
const { createElement: h } = require("react");
const { renderToString } = require("react-dom/server");

const comment = "<img src=x onerror=alert(1)>"; // what an attacker types into a comment box
console.log(renderToString(h("p", null, comment)));
console.log(renderToString(h("a", { href: "javascript:alert(1)" }, "my site")));
console.log(renderToString(h("div", { dangerouslySetInnerHTML: { __html: comment } })));
Result
<p>&lt;img src=x onerror=alert(1)&gt;</p>
<a href="javascript:throw new Error(&#x27;React has blocked a javascript: URL as a security precaution.&#x27;)">my site</a>
<div><img src=x onerror=alert(1)></div>
What’s happening
  1. Rendered as a child, the attacker's markup became &lt;img …&gt;: text, not an element. This is why JSX is XSS-safe by default, and it applies equally to Server and Client Components.
  2. A javascript: URL in href was replaced with one that throws "React has blocked a javascript: URL as a security precaution." (React 16.9 only warned about these; React 19 blocks them.) Other schemes such as data: aren't blocked, so still validate user-supplied URLs (allow https: and mailto: only).
  3. dangerouslySetInnerHTML passed the string through untouched: a real <img onerror> element. Anything you render this way (Markdown output, CMS HTML) must be sanitised first with a library like DOMPurify, and a CSP is your second line of defence.
AttackWhat Next.js and React do by defaultWhat's left to you
XSS (injected script)JSX escapes text; javascript: URLs blocked; HttpOnly cookies can't be read by scriptsSanitise anything passed to dangerouslySetInnerHTML; validate URLs; add a CSP; never put user input inside <script> or JSON-LD without escaping
CSRF (another site makes the user's browser send a request)Server Actions accept only POST and abort when Origin doesn't match the host; SameSite=Lax cookies aren't sent on cross-site POSTsRoute Handlers have no Origin check (a cross-origin, form-encoded POST with Origin: https://evil.example to a test Route Handler ran and returned 200): check Origin yourself or require a non-simple header for state-changing methods; never mutate on GET
SSRF (your server is tricked into fetching an internal URL)The image optimiser only fetches allow-listed remotePatterns and refuses local IPs unless dangerouslyAllowLocalIP is set (an option added in 16)Validate any URL your server fetches on a user's behalf (webhooks, link previews, imports); don't trust Host for building URLs
AdvancedThe production security checklist

The checklist

  • Patch. Track the Next.js blog and GitHub advisories, and keep next, react, react-dom and sharp on a patched line. Most of the table above had no workaround.
  • Authorise in the DAL, in every page, Server Action and Route Handler; proxy only for optimistic redirects (see Authorisation close to the data).
  • Validate every input: form data, bound arguments, params, searchParams, headers, cookies. Bracketed folders like [id] are user input.
  • Return DTOs: only the fields the UI needs, from loaders and from actions (an action's return value is sent to the client).
  • Keep Server Action defaults. They accept only POST and compare Origin with Host/X-Forwarded-Host (my cross-origin test with Origin: https://evil.example was aborted with "x-forwarded-host header with value localhost:4509 does not match origin header with value evil.example… Aborting the action."). Add only the hosts you need to serverActions.allowedOrigins; keep bodySizeLimit (1 MB by default) small; rate-limit expensive actions; set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY when self-hosting several instances. More in Securing Server Actions.
  • Lock down images: narrow images.remotePatterns (prefer full paths over whole hostnames); leave dangerouslyAllowLocalIP off (it exists for VPCs and is an SSRF risk); keep dangerouslyAllowSVG off, or pair it with contentDispositionType: 'attachment' and contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;" as the docs advise; consider maximumRedirects: 0 (redirects don't re-check remotePatterns).
  • Secrets: only in server-only modules; nothing secret behind NEXT_PUBLIC_; .env*.local git-ignored; rotate after any incident like React2Shell.
  • Headers: HSTS, nosniff, Referrer-Policy, frame-ancestors, a CSP (nonce or static), poweredByHeader: false.
  • Self-hosting: put a reverse proxy in front that sets Host/X-Forwarded-* itself and strips internal headers (x-middleware-subrequest on old versions); never expose next dev (the September 2026 release included CVE-2026-94486, data readable through the dev server's MCP endpoint).
  • Leave production source maps off (productionBrowserSourceMaps is off by default) unless you upload them to an error tracker privately.
  • Audit the risky files first, as the docs suggest: proxy.ts, route.ts files, every "use server" file, every "use client" file's props, and anything that reads process.env.

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.