Authentication & Authorization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. It covers the App Router in depth, which is where the official authentication guides live, and the Pages Router where its code differs (Auth.js in getServerSideProps, and the pitfalls of using it there). Caching changes how a session may be read, so each example says which of the two models it uses: the previous model (the default, in which nothing is cached unless you ask) or the opt-in Cache Components model (cacheComponents: true), which has a section of its own. Every example was built with next build and run with next start and curl, or with a headless browser, on Next.js 16.3.8, Node.js 24.16 and macOS, in scratch copies of the Bookshelf catalogue that the rest of this section grows. No real account of any provider was used: the Auth.js, Better Auth and custom examples ran end to end against a local data source, and the Auth0 and Clerk examples were built or type-checked only, which each of them says. Every example is followed by the official page it derives from.

The page answers one question: where does each check live, and what does a request that skips the user interface reach? The short answer is that the session is established once, read through one server-only function, and verified again next to the data every time something is read or changed. The Proxy, the layouts and the client components only improve the user experience. If OAuth 2.0 and OpenID Connect are new, read the protocol background first, in OAuth 2.0 and OpenID Connect; this page links to the relevant chapters instead of repeating them.

Authentication, Session Management and Authorization

The official guide splits the topic into three concepts, and the rest of this page follows the same split:

Table 1. The three concerns of the official authentication guide
Concern Question it answers Typical Next.js building blocks

Authentication

Is this user who they say they are?

A form posting to a Server Action, an OAuth or OIDC redirect, a magic link, a passkey. Handled by a library or by your code.

Session management

How does the next request prove it is the same user?

A signed or encrypted cookie read with cookies() (a stateless session), or an opaque id in a cookie that points to a database row (a database session).

Authorization

What may this user read or change?

Checks in the Data Access Layer, Server Components, Server Actions and Route Handlers; optimistic redirects in Proxy; forbidden() and unauthorized().

Authentication proves identity once; authorization is asked on every read and write. Next.js has several entry points to the same data (page requests, client navigations, Server Actions, Route Handlers, fetch from a script), and each entry point has to ask the question for itself.

The Sign-In and Authorization Flow

The sequence below follows one login and two later requests. The first request shows the layers working together; the second shows the case that decides whether the application is secure: a request that carries a valid-looking cookie whose owner has lost a privilege.

sequenceDiagram participant B as Browser participant P as Proxy (optimistic) participant S as Server Action / Server Component participant D as Data Access Layer participant DB as Data source B->>S: POST login form (email, password) S->>S: validate with Zod S->>DB: find user, compare password hash DB-->>S: user row S-->>B: 303 to /dashboard + Set-Cookie (HttpOnly, SameSite, Max-Age) B->>P: GET /dashboard + cookie P->>P: verify signature only (no database) P-->>S: next() S->>D: verifySession() D->>D: decrypt cookie D->>DB: re-read the user (role, still exists?) DB-->>D: current user row D-->>S: userId, role S-->>B: 200 HTML (or 403 / 401 UI) Note over B,DB: Later, the admin role is removed in the database B->>P: GET /admin + same cookie P-->>S: next() (signature still valid) S->>D: verifySession() D->>DB: re-read the user DB-->>D: role is now "user" D-->>S: role "user" S-->>B: forbidden() renders the 403 page
Figure 1. Sign-in, optimistic check in Proxy and authoritative check in the Data Access Layer

The Proxy cannot know that the role changed, because it deliberately does not touch the database. The Data Access Layer can. That asymmetry is the reason for the two kinds of check described in Optimistic Checks in Proxy and The Data Access Layer.

Table 2. Where each kind of check runs
Place Kind of check What it is good for

Proxy (proxy.ts)

Optimistic: cookie only

Cheap pre-filtering and redirects for the whole site, and protecting static routes that share data between users.

Data Access Layer (verifySession)

Authoritative: cookie plus data source

The single place every read goes through. Called from pages, leaf components, Server Actions and Route Handlers.

Server Component or page

Authoritative, through the DAL

Role-based rendering, redirect(), forbidden(), unauthorized().

Server Action

Authoritative, through the DAL

Every action is a public POST endpoint; it authorises itself.

Route Handler

Authoritative, through the DAL

Returns 401 or 403 as a response, because there is no page to redirect to.

Layout

Not a security boundary

Shell data such as the user’s name. Does not re-render on navigation (see Why a Layout Alone Is Not Enough).

Client Component

Not a security boundary

Showing or hiding UI. Receives only the data the server chose to pass.

Session Management

A session keeps the authenticated state across requests. The official guide names two families and recommends a session library (iron-session or jose) over hand-written cryptography; the examples below use jose because it is what the guide’s own samples use and it runs in both the Node.js and the Edge runtimes.

Table 3. Stateless and database sessions
Stateless (cookie holds the data) Database (cookie holds an id)

Stored in

The browser cookie, signed or encrypted

A table or key-value store on the server; the cookie carries only an encrypted id

Cost per request

One signature check, no I/O

One lookup (cache it per request with React cache, and server-side for the session’s lifetime if you can)

Revocation, "sign out everywhere"

Not possible before expiry without a server-side denylist (which turns it into a database session)

Delete the rows

Stale privileges

The role inside the cookie lives until it expires; re-read the user to be sure

The row always reflects the server’s view

Cookie size

Limited to about 4 KB; keep the payload minimal

Constant

Works in Proxy

Yes: verify the signature

Only as an optimistic check on the cookie; do not query the database there

The official guide describes the trade-off as simple versus secure; the practical summary is that stateless sessions are enough for many applications provided the Data Access Layer re-reads the user, and that database sessions pay for revocation, device lists and "last seen" features.

Stateless Sessions with a Signed Cookie

A stateless session needs a secret, a pair of functions to sign and verify the payload, and a cookie set on the server. Generate the secret once, keep it in the environment, and never commit it:

openssl rand -base64 32      # a 32-byte random secret; paste it into .env.local
# .env.local (never committed)
# SESSION_SECRET=<the generated value>

Reference it as process.env.SESSION_SECRET. The module below is marked server-only so that importing it from a Client Component fails the build instead of leaking the secret into a bundle (see Configuration, TypeScript and Environment Variables). It fixes four faults that the requester books' login examples share (listed in Where the Books Are Outdated): the secret comes from the environment, the token expires, a tampered cookie yields undefined instead of an uncaught exception, and the cookie is HttpOnly, SameSite and (in production) Secure.

lib/definitions.ts
import * as z from "zod";

export type Role = "admin" | "user";

export type SessionPayload = {
  userId: string;
  role: Role; // a hint for optimistic checks only
  expiresAt: Date;
};

export const LoginSchema = z.object({
  email: z.email({ error: "Please enter a valid email." }).trim(),
  password: z.string().min(1, { error: "Password is required." }),
});

export type FormState =
  | { errors?: { email?: string[]; password?: string[] }; message?: string }
  | undefined;
lib/session.ts
import "server-only";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import type { SessionPayload } from "@/lib/definitions";

const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET);
export const COOKIE_NAME = "session";

export async function encrypt(payload: SessionPayload) {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("7d")
    .sign(encodedKey);
}

export async function decrypt(session: string | undefined = "") {
  try {
    const { payload } = await jwtVerify(session, encodedKey, { algorithms: ["HS256"] });
    return payload as unknown as SessionPayload;
  } catch {
    return undefined; // tampered, expired or missing: never throw to the user
  }
}

export async function createSession(userId: string, role: SessionPayload["role"]) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const session = await encrypt({ userId, role, expiresAt });
  const cookieStore = await cookies();
  cookieStore.set(COOKIE_NAME, session, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

export async function deleteSession() {
  (await cookies()).delete(COOKIE_NAME);
}
Table 4. Cookie options for a session cookie
Option Why

httpOnly: true

JavaScript on the page cannot read the cookie, which limits what an XSS bug can steal.

secure: true

Sent over HTTPS only. The example enables it when NODE_ENV is production, because a browser may refuse a Secure cookie on plain HTTP in development.

sameSite: "lax"

Not sent on cross-site sub-requests such as a form posted from another site; part of the defence against CSRF (see CORS for the cross-origin side).

expires or maxAge

A session without an expiry never ends. The signed token carries its own expiry as well (setExpirationTime), so a copied cookie stops working on time.

path: "/"

Sent with every request to the site.

Cookies must be set on the server. Setting them in the browser lets client code tamper with them, and the session cookie should not be readable from the client in the first place. The payload should hold the minimum (a user id and a role hint), never an email, a phone number or anything sensitive: a signed token is readable by whoever holds it.

The Login Server Action

The login form posts to a Server Action. The action validates the fields with Zod and returns early on failure (so the user database is not queried with garbage), checks the password against a salted hash, answers the same message for an unknown user and a wrong password (so the form cannot be used to discover which emails are registered), creates the session and redirects. redirect() throws, so it comes last and outside any try/catch.

app/actions/auth.ts
"use server";

import * as z from "zod";
import { redirect } from "next/navigation";
import { LoginSchema, type FormState } from "@/lib/definitions";
import { createSession, deleteSession } from "@/lib/session";
import { findUserByEmail, verifyPassword } from "@/lib/users";

export async function login(state: FormState, formData: FormData): Promise<FormState> {
  const parsed = LoginSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });
  if (!parsed.success) {
    return { errors: z.flattenError(parsed.error).fieldErrors };
  }

  const user = await findUserByEmail(parsed.data.email);
  // Same message for "no such user" and "wrong password"
  if (!user || !verifyPassword(parsed.data.password, user.passwordHash)) {
    return { message: "Invalid email or password." };
  }

  await createSession(user.id, user.role);
  redirect("/dashboard");
}

export async function logout() {
  await deleteSession();
  redirect("/login");
}
app/login/login-form.tsx
"use client";

import { useActionState } from "react";
import { login } from "@/app/actions/auth";

export function LoginForm() {
  const [state, action, pending] = useActionState(login, undefined);
  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="username" />
      {state?.errors?.email && <p>{state.errors.email}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" autoComplete="current-password" />
      {state?.errors?.password && <p>{state.errors.password}</p>}

      {state?.message && <p role="alert">{state.message}</p>}
      <button disabled={pending} type="submit">Log in</button>
    </form>
  );
}

The password check and the user table live in a stand-in module, which is where a real database call goes. It uses the scrypt function that ships with Node.js, so the example has no dependency, and it seeds the two test users from an environment variable instead of a literal:

lib/users.ts
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import type { Role } from "@/lib/definitions";

export type UserRow = {
  id: string;
  name: string;
  email: string;
  role: Role;
  passwordHash: string;
};

function hashPassword(password: string): string {
  const salt = randomBytes(16);
  return `${salt.toString("hex")}:${scryptSync(password, salt, 32).toString("hex")}`;
}

export function verifyPassword(password: string, stored: string): boolean {
  const [salt, hash] = stored.split(":");
  const candidate = scryptSync(password, Buffer.from(salt, "hex"), 32);
  return timingSafeEqual(candidate, Buffer.from(hash, "hex"));
}

// Stands in for a database table. Seeded from an env var, never a literal.
const seed = process.env.SEED_PASSWORD ?? "";
const table: UserRow[] = [
  { id: "u1", name: "Ada", email: "ada@example.com", role: "admin", passwordHash: hashPassword(seed) },
  { id: "u2", name: "Bob", email: "bob@example.com", role: "user", passwordHash: hashPassword(seed) },
];

export async function findUserByEmail(email: string) {
  return table.find((u) => u.email === email);
}
export async function findUserById(id: string) {
  return table.find((u) => u.id === id);
}
export async function demoteToUser(id: string) {
  const u = table.find((x) => x.id === id);
  if (u) u.role = "user";
}

Run against next start, the form behaves as follows (the login was driven by posting the form’s hidden fields with curl, and again with a headless Chrome):

Table 5. Observed behaviour of the login action
Input Result

Correct email and password

303 See Other to /dashboard, with Set-Cookie: session=<redacted>; Path=/; Expires=…​; Secure; HttpOnly; SameSite=lax

Correct email, wrong password

200 with the page re-rendered and the message "Invalid email or password."

Unknown email

The same 200 and the same message

Sign-out is the mirror image: (await cookies()).delete("session") and a redirect, which the logout action above does. Because the cookie is the whole session, deleting it ends the session on that browser only; with a stateless session the copied token stays valid until it expires. If that is not acceptable, use a database session.

Database Sessions

With a database session the cookie holds only an opaque id (signed, so the server can reject forgeries cheaply) and the state lives in a table. The module below keeps the "table" in memory so that it runs anywhere; replace the Map with your database. The guide notes that keeping the cookie and the row in sync also lets Proxy do an optimistic check on the cookie.

lib/db-session.ts
import "server-only";
import { randomUUID } from "node:crypto";
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";

const key = new TextEncoder().encode(process.env.SESSION_SECRET);
const COOKIE = "sid";

type SessionRow = { id: string; userId: string; expiresAt: number };
const sessions = new Map<string, SessionRow>(); // stands in for a "sessions" table

export async function createDbSession(userId: string) {
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
  const row: SessionRow = { id: randomUUID(), userId, expiresAt };
  sessions.set(row.id, row); // 1. persist the session

  const sealed = await new SignJWT({ sid: row.id }) // 2. the cookie only carries the id
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("7d")
    .sign(key);
  (await cookies()).set(COOKIE, sealed, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    expires: expiresAt,
    path: "/",
  });
}

export async function readDbSession() {
  const sealed = (await cookies()).get(COOKIE)?.value;
  if (!sealed) return null;
  try {
    const { payload } = await jwtVerify(sealed, key, { algorithms: ["HS256"] });
    const row = sessions.get(String(payload.sid));
    if (!row || row.expiresAt < Date.now()) return null; // revoked or expired
    return row;
  } catch {
    return null;
  }
}

// "Sign out everywhere": impossible with a pure stateless cookie.
export function revokeAllSessions(userId: string) {
  for (const [id, row] of sessions) if (row.userId === userId) sessions.delete(id);
}

A route handler wrapped the three functions for the test: two devices logged in as the same user, both calls to the session endpoint answered 200, an administrator revoked all sessions of that user, and the very same cookies were then answered with 401 ({"error":"Not authenticated"}). A stateless cookie cannot do that. The price is the extra lookup, which is why the guide suggests caching the lookup for the lifetime of the session and combining it with other queries.

Where the Session Is Read: Three Layers

Authorization has three layers, each with a different cost and a different promise. The next three sections build them in order, using the stateless session above.

  • Proxy sees every request before routing, runs once per navigation and prefetch, and should only read the cookie.

  • The Data Access Layer is the single server-only function that reads the session and the user, and everything that touches data calls it.

  • The call site (page, leaf component, Server Action, Route Handler) decides what to do with the answer: render, redirect, 401, 403.

Optimistic Checks in Proxy

Proxy (proxy.ts, the Next.js 16 name for middleware.ts) runs before every matched request, including prefetches, so it is the natural place for cheap redirects: send signed-out visitors away from /dashboard, send signed-in visitors away from /login. The official guide limits it to optimistic checks, reading the session from the cookie and avoiding database calls, because a database round trip on every navigation and prefetch is slow. In Next.js 16 Proxy runs on the Node.js runtime, so check that your auth and session libraries support it. The full Proxy reference, matcher and evaluation order are in Redirects, Rewrites and Proxy.

proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { COOKIE_NAME, decrypt } from "@/lib/session";

const protectedRoutes = ["/dashboard", "/admin"];

export default async function proxy(req: NextRequest) {
  const path = req.nextUrl.pathname;
  const isProtected = protectedRoutes.some((r) => path === r || path.startsWith(`${r}/`));

  // Optimistic: only verify the signature, never hit the database here
  const session = await decrypt(req.cookies.get(COOKIE_NAME)?.value);

  if (isProtected && !session?.userId) {
    return NextResponse.redirect(new URL("/login", req.nextUrl));
  }
  if (path === "/login" && session?.userId) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

Probed with curl on next start (no cookie unless stated):

Table 6. Proxy behaviour (previous model)
Request Status Why

GET /dashboard, no cookie

307 to /login

Proxy: no valid signature

GET /dashboard, cookie session=abc.def.ghi

307 to /login

decrypt returned undefined; no 500

GET /login, valid cookie

307 to /dashboard

Proxy: already signed in

GET /api/me, no cookie

401 from the Route Handler

The matcher excludes /api, so Proxy never sees it; the handler authorises itself

Proxy is a convenience, not a lock. Two facts from the official material make that concrete. First, the guide says that while Proxy is useful for initial checks it must not be the only line of defence, and that most checks belong as close as possible to the data source. Second, in March 2025 the Next.js team disclosed CVE-2025-29927, an authorization bypass in which a request carrying the internal x-middleware-subrequest header skipped middleware entirely, and patched it in 12.3.5, 13.5.9, 14.2.25 and 15.2.3. Any application that relied on middleware as its only check let such requests through; the bypass did not reach an application’s checks in its Data Access Layer. Keep Next.js on a supported release for the same reason (16.x is Active LTS, 15.x Maintenance LTS as of 2026-10-03).

Proxy is still the right tool for one job that the Data Access Layer cannot do: static routes that share data between users, such as content behind a paywall. A static page is built once, so a verifySession() call in it would run at build time, not per request; the optimistic check in Proxy is what keeps unauthenticated visitors away from it.

The Data Access Layer

The guide recommends a Data Access Layer (DAL): one module that centralises data requests and authorization logic. It exports a verifySession() function that every other function calls first. Three details matter:

  • It is marked server-only, so a Client Component cannot import it.

  • It wraps the function in React cache, so calling it from a layout, a page and a leaf component in the same render runs the check once.

  • It is authoritative: after decrypting the cookie it re-reads the user from the data source, so a cookie that says admin cannot outlive the database row that says user.

lib/dal.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { COOKIE_NAME, decrypt } from "@/lib/session";
import { findUserById } from "@/lib/users";

// Authoritative check: verify the signed cookie, then re-read the user
// from the data source so a stale cookie cannot keep old privileges.
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get(COOKIE_NAME)?.value;
  const session = await decrypt(cookie);
  if (!session?.userId) redirect("/login");

  const user = await findUserById(session.userId);
  if (!user) redirect("/login");
  return { isAuth: true as const, userId: user.id, role: user.role };
});

// Data Transfer Object: return only what the UI may see.
export const getUser = cache(async () => {
  const session = await verifySession();
  const user = await findUserById(session.userId);
  return user && { id: user.id, name: user.name, role: user.role };
});

getUser also shows the Data Transfer Object rule: return only the fields the caller may see (id, name, role), never the row with its password hash. The sibling page on data security, which this section covers next, goes deeper into DTOs, server-only and tainting.

To prove that the DAL, not the cookie, is the authority, a cookie claiming role: "admin" was signed with the real secret for a user whose row says user. Proxy accepted it (the signature is valid). The Data Access Layer did not:

Table 7. A correctly signed cookie that lies about the role
Request Status

GET /admin with the forged cookie

403

GET /api/admin with the forged cookie

403

GET /dashboard with the forged cookie

200 (the user exists and may see the dashboard)

(The secret was read from the scratch .env.local to mint the cookie; nobody without the secret can do that, which is exactly why the role in the cookie is only a hint.)

Protecting Pages, Layouts, Server Actions and Route Handlers

Server Components and Pages

A page that must be signed-in and role-aware calls the DAL at the top and decides. The same function can run in a leaf component, which lets a page show or hide an element per user while the check still happens at render time on the server:

app/dashboard/page.tsx
import { getUser } from "@/lib/dal";
import { logout } from "@/app/actions/auth";

export default async function DashboardPage() {
  const user = await getUser(); // verifySession() runs inside
  return (
    <main>
      <h1>Welcome, {user?.name}</h1>
      <p>Role: {user?.role}</p>
      <form action={logout}>
        <button type="submit">Log out</button>
      </form>
    </main>
  );
}

Three details of the previous model, observed on next start: redirect() from a page that has not started streaming returns a real 307; forbidden() returns a real 403; and a page protected this way is dynamic (ƒ in the route table) because it reads cookies(). Client Components cannot import the DAL. Run verifySession() or getUser() in a parent Server Component and pass the result down as props or through a context provider, and use React’s taintUniqueValue to keep sensitive fields out of client props (the data-security page covers it).

Why a Layout Alone Is Not Enough

The official guide is explicit: be cautious about checks in layouts. A layout does not re-render on navigation because of Partial Rendering, so a check that lives only there does not run on every route change, and a layout does not control whether the rest of the route renders. Route segments and parallel route slots are rendered by the router; a layout that hides or swaps them does not stop them from running or from appearing in the RSC payload. A common single-page-app pattern, returning null from a layout when the user is not authorized, is therefore not recommended: Next.js has several entry points, and it does not stop nested segments or Server Actions from being reached. The guidance is to check close to the data source or to the component that is conditionally rendered. A layout may fetch the user for the navigation bar, but the check belongs in the getUser() of the DAL, so that it runs wherever the function is called. See Layouts, Templates and Special Files for the rendering behaviour behind this.

The following is the anti-pattern, a members-only section whose only check is in its layout, with a Server Action next to it that trusts the layout:

app/members/layout.tsx
import type { ReactNode } from "react";
import { verifySession } from "@/lib/dal";

// Anti-pattern: the only check lives in the layout.
export default async function MembersLayout({ children }: { children: ReactNode }) {
  await verifySession();
  return <section>{children}</section>;
}
app/members/[slug]/page.tsx
import Link from "next/link";
import { deleteNoteSafe, deleteNoteUnsafe } from "./actions";

// Anti-pattern: no check here, the page trusts its layout.
export default async function MemberPage({ params }: PageProps<"/members/[slug]">) {
  const { slug } = await params;
  return (
    <>
      <p>Members-only content for {slug}</p>
      <Link href={slug === "a" ? "/members/b" : "/members/a"} prefetch={false}>Next member</Link>
      <form action={deleteNoteUnsafe}><button>Delete (unsafe)</button></form>
      <form action={deleteNoteSafe}><button>Delete (safe)</button></form>
    </>
  );
}
app/members/[slug]/actions.ts
"use server";

import { verifySession } from "@/lib/dal";

// Anti-pattern: reachable by POST even though the layout above redirects.
export async function deleteNoteUnsafe() {
  console.log("deleteNoteUnsafe executed");
}

// Fixed: the action authorises itself.
export async function deleteNoteSafe() {
  const session = await verifySession();
  console.log(`deleteNoteSafe executed for ${session.userId}`);
}

An ordinary anonymous GET /members/a is redirected by the layout (307 to /login), so the page looks protected. Then the two actions were invoked the way any script can, by posting the form’s hidden $ACTION_ID_…​ field without a session (the values come from the form HTML; they are not secrets, because every visitor who can see the form can read them):

Table 8. The same two actions called with and without a session
Action Caller Observed

deleteNoteUnsafe (relies on the layout)

anonymous

The action executed (the server log printed deleteNoteUnsafe executed); the response was then a 307 to /login because the page re-rendered under the layout

deleteNoteUnsafe

signed in

Executed, 200

deleteNoteSafe (calls verifySession())

anonymous

Not executed; verifySession() redirected, 303 to /login

deleteNoteSafe

signed in

Executed as the signed-in user, 200

The redirect in the response is what makes the unsafe version deceptive: the caller is bounced, yet the side effect already happened. Replaying a client router-state header against the page did not return the page content to an anonymous caller in this version (the layout also ran on the server), so the demonstration above uses the Server Action rather than a leaked page; do not take that as permission to rely on the layout, because the guide’s reasoning about Partial Rendering and parallel slots still applies.

Server Actions

The guide says to treat Server Actions with the same security considerations as public-facing API endpoints. The mechanics (action IDs, closure encryption, allowedOrigins, input validation) are in Server Actions and Mutations; the authorization rule is the one the safe action above follows: call the DAL first, take only references from the client (an id), re-read the row, and compare its owner with the session. Returning early when the role is wrong (return null) is acceptable in the official sample; calling forbidden() (below) is clearer for callers that render the result.

Route Handlers

A Route Handler has no page to redirect to, so it answers with a status code. The guide’s sample checks for a session (401) and then for the role (403); the same two-tier answer, using the stateless session and the data source:

app/api/admin/route.ts
import { cookies } from "next/headers";
import { COOKIE_NAME, decrypt } from "@/lib/session";
import { findUserById } from "@/lib/users";

export async function GET() {
  const session = await decrypt((await cookies()).get(COOKIE_NAME)?.value);
  const user = session && (await findUserById(session.userId));
  if (!user) {
    return Response.json({ error: "Not authenticated" }, { status: 401 });
  }
  if (user.role !== "admin") {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
  return Response.json({ report: "admin-only data" });
}
Table 9. Route Handler results (previous model)
Caller Status Body

anonymous

401

{"error":"Not authenticated"}

signed in as a user

403

{"error":"Forbidden"}

signed in as an admin

200

the protected JSON

The back-end-for-frontend patterns that put these handlers in front of other services (and why the access token stays on the server) are in Route Handlers and the Backend for Frontend; the protocol side is in OAuth for Browser-Based Apps.

forbidden() and unauthorized()

unauthorized() throws an error that renders a 401 page ("you are not signed in") and forbidden() throws one that renders a 403 page ("you are signed in but may not"). They mirror notFound(): you call them in the render path, they terminate rendering of the segment where they were thrown, Next.js injects <meta name="robots" content="noindex">, and the nearest unauthorized.js or forbidden.js file supplies the UI. Both were added in Next.js 15.1.0 and are still experimental in 16.3.8: the function pages say they are "not recommended for production" and the feature is behind a flag.

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

const nextConfig: NextConfig = {
  cacheComponents: process.env.CACHE_COMPONENTS === "1",
  experimental: {
    authInterrupts: true,
  },
};

export default nextConfig;

The two UI files and a page that uses forbidden():

app/forbidden.tsx
export default function Forbidden() {
  return (
    <main>
      <h1>403 - Forbidden</h1>
      <p>You do not have access to this page.</p>
    </main>
  );
}
app/unauthorized.tsx
import Link from "next/link";

export default function Unauthorized() {
  return (
    <main>
      <h1>401 - Unauthorized</h1>
      <p>
        Please <Link href="/login">log in</Link> to continue.
      </p>
    </main>
  );
}
app/admin/page.tsx
import { forbidden } from "next/navigation";
import { verifySession } from "@/lib/dal";

export default async function AdminPage() {
  const session = await verifySession();
  if (session.role !== "admin") forbidden();
  return <h1>Admin dashboard</h1>;
}
app/profile/page.tsx
import { cookies } from "next/headers";
import { unauthorized } from "next/navigation";
import { COOKIE_NAME, decrypt } from "@/lib/session";

export default async function ProfilePage() {
  const session = await decrypt((await cookies()).get(COOKIE_NAME)?.value);
  if (!session) unauthorized();
  return <h1>Your profile</h1>;
}
Table 10. Observed with authInterrupts on (previous model)
Request Status Page content

GET /admin, signed in as a user

403

"403 - Forbidden" and <meta name="robots" content="noindex">

GET /profile, anonymous

401

"401 - Unauthorized", with a link to the login page, and noindex

GET /profile, signed in

200

the profile

They also work in Server Actions and Route Handlers: unauthorized() called in a Route Handler answered an anonymous GET with 401 and an empty body, and 200 with the JSON for a signed-in caller. For an API, an explicit Response.json({…​}, { status: 401 }) is usually friendlier because it carries a message.

Rules and limits from the reference pages:

  • forbidden() cannot be called in the root layout.

  • They work by throwing. return forbidden() is unnecessary (the return type is never), and a try/catch around the call swallows the interrupt so no UI renders; use unstable_rethrow to let it through.

  • Call them in the render path: a component, or a function a component awaits. A call in an un-awaited promise throws where nothing catches it (development logs NEXT_HTTP_ERROR_FALLBACK;403 as an unhandled rejection).

  • Status codes depend on streaming. If the check runs inside a <Suspense> boundary, the response has begun as 200 and the status cannot change; the interrupt UI still renders in place. To get a real 403 the check must run before the response streams. Under Cache Components every dynamic route streams a static shell first, so the official page says to run that check in Proxy instead (see Authentication with Cache Components).

Auth.js v5 (next-auth@beta)

Auth.js is the most widely used library for this job in the App Router, so it gets the most space here. Two facts about its status decide how much to rely on it, both verified on 2026-10-03:

  • Version. The latest dist-tag of next-auth on npm is 4.24.15. Version 5, the one with the universal auth() and the AUTH_* environment variables, is installed with next-auth@beta and is at 5.0.0-beta.32; there is no stable v5 tag.

  • Ownership. The Auth.js site now states that the Auth.js project is part of Better Auth. Its migration guide says the Better Auth team continues to handle security patches and critical issues for Auth.js, and recommends Better Auth for new projects while saying there is no urgency to migrate a working setup.

A new project should weigh that before choosing Auth.js; an existing one on v4 or v5 can stay. Everything in this section was run with next-auth@5.0.0-beta.32.

Table 11. What v5 changed relative to v4
Area v5

Server API

One universal auth() replaces getServerSession, getSession, withAuth and getToken. In Proxy it is exported as the proxy, in Route Handlers it wraps the handler, in Server Components it is called.

Configuration

A root auth.ts that calls NextAuth() and exports handlers, auth, signIn and signOut; no more authOptions passed around.

Environment

All variables are prefixed AUTH_; NEXTAUTH_ is no longer used. Provider credentials named AUTH_<PROVIDER>_ID and AUTH_<PROVIDER>_SECRET are inferred.

Cookies

The next-auth cookie prefix became authjs (the session cookie is authjs.session-token).

Next.js

Requires Next.js 14 or later; the migration guide notes that middleware.ts is proxy.ts as of Next.js 16.

Install and Environment

npm install next-auth@beta
npx auth secret        # writes AUTH_SECRET to .env.local

AUTH_SECRET is the only mandatory variable: a random value of at least 32 characters used to encrypt tokens and email verification hashes (openssl rand -base64 32 is an alternative to the CLI). Others you will meet:

Table 12. Auth.js environment variables
Variable Purpose

AUTH_SECRET

Encrypts the JWT and other cookies. Required.

AUTH_GITHUB_ID, AUTH_GITHUB_SECRET

The GitHub OAuth app credentials; inferred when you pass GitHub to providers without arguments. The same pattern applies to every provider, and some need an AUTH_<PROVIDER>_ISSUER.

AUTH_TRUST_HOST

Set to true behind a reverse proxy so that Auth.js trusts X-Forwarded-Host and X-Forwarded-Proto; inferred on supported hosts such as Vercel. It was also needed to run next start on localhost in the scratch app (without it the response was an UntrustedHost error).

AUTH_URL

The canonical URL; usually unnecessary because the host is detected from the request.

The Configuration, the Route Handler and Proxy

auth.ts
import NextAuth, { type DefaultSession } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import "next-auth/jwt"; // makes the declare module below an augmentation
import { findUserByEmail, verifyPassword, type Role } from "@/lib/users";

declare module "next-auth" {
  interface Session {
    user: { id: string; role: Role } & DefaultSession["user"];
  }
  interface User {
    role?: Role;
  }
}
declare module "next-auth/jwt" {
  interface JWT {
    uid?: string;
    role?: Role;
  }
}

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    // Reads AUTH_GITHUB_ID and AUTH_GITHUB_SECRET from the environment
    GitHub,
    Credentials({
      credentials: { email: {}, password: {} },
      authorize: async (credentials) => {
        const user = await findUserByEmail(String(credentials?.email ?? ""));
        if (!user || !verifyPassword(String(credentials?.password ?? ""), user.passwordHash)) {
          return null; // Auth.js turns this into a CredentialsSignin error
        }
        return { id: user.id, name: user.name, email: user.email, role: user.role };
      },
    }),
  ],
  session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 7 },
  pages: { signIn: "/login" },
  callbacks: {
    // Runs in Proxy when auth is exported as proxy: an optimistic check
    authorized({ auth, request: { nextUrl } }) {
      const protectedPath = ["/dashboard", "/admin"].some((p) => nextUrl.pathname.startsWith(p));
      return protectedPath ? !!auth?.user : true;
    },
    jwt({ token, user }) {
      if (user) {
        token.uid = user.id;
        token.role = user.role ?? "user";
      }
      return token;
    },
    session({ session, token }) {
      session.user.id = token.uid ?? "";
      session.user.role = token.role ?? "user";
      return session;
    },
  },
});
app/api/auth/[…​nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
proxy.ts
export { auth as proxy } from "@/auth";

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};

What each piece does:

  • NextAuth({ providers, …​ }) returns handlers, auth, signIn and signOut. The route handler mounts handlers under /api/auth/* (sign-in, callback, session, CSRF and providers endpoints).

  • GitHub is passed without options: the client id and secret come from AUTH_GITHUB_ID and AUTH_GITHUB_SECRET. The callback URL to register in GitHub is https://<your-host>/api/auth/callback/github.

  • The Credentials provider forwards a login form to your authorize function; returning null makes Auth.js fail the sign-in with CredentialsSignin. It does not persist anything by default. The Auth.js page presents it for external authentication mechanisms and traditional username and password flows; prefer an OAuth, passkey or email provider where you can, and see Authentication Methods, 2FA and Passwordless.

  • session.strategy: "jwt" is the default when no database adapter is configured: a JWT in an HttpOnly cookie. With an adapter the default becomes "database", where the cookie holds a session id. The jwt and session callbacks copy the user id and role into the token and then into the session, which the declare module blocks type. The import "next-auth/jwt" line makes the second block an augmentation; without it TypeScript reports that the module cannot be found.

  • export { auth as proxy } from "@/auth" makes Proxy run auth() on every matched request, which also keeps the session alive by refreshing its expiry. The authorized callback decides what an unauthenticated request may reach: returning false redirects to the sign-in page. It is an optimistic check, with the same limits as any Proxy check.

  • On Next.js 14 and 15 the same file is middleware.ts and the export is export { auth as middleware }.

  • A database adapter that cannot run on the Edge needs the configuration split in two (an auth.config.ts without the adapter, used by Proxy or middleware, and the full auth.ts). Whether the split is still needed on the Node.js Proxy of Next.js 16 depends on the adapter; the scratch build above ran the full file in Proxy, without an adapter.

Protecting Pages, Route Handlers and Actions with auth()

app/login/page.tsx
import { signIn } from "@/auth";

export default function LoginPage() {
  return (
    <main>
      <h1>Log in</h1>
      <form
        action={async () => {
          "use server";
          await signIn("github", { redirectTo: "/dashboard" });
        }}
      >
        <button type="submit">Continue with GitHub</button>
      </form>
      <form
        action={async (formData: FormData) => {
          "use server";
          await signIn("credentials", formData, { redirectTo: "/dashboard" });
        }}
      >
        <input name="email" type="email" autoComplete="username" />
        <input name="password" type="password" autoComplete="current-password" />
        <button type="submit">Log in</button>
      </form>
    </main>
  );
}
app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { auth, signOut } from "@/auth";

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user) redirect("/login"); // authoritative check, next to the data
  return (
    <main>
      <h1>Welcome, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
      <form
        action={async () => {
          "use server";
          await signOut({ redirectTo: "/login" });
        }}
      >
        <button type="submit">Log out</button>
      </form>
    </main>
  );
}
app/api/me/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";

export const GET = auth(function GET(req) {
  if (!req.auth?.user) {
    return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
  }
  const { id, name, role } = req.auth.user;
  return NextResponse.json({ id, name, role });
});

auth() in a Server Component, Server Action or getServerSideProps returns the session or null; wrapping a Route Handler in auth(…​) adds req.auth. Build it into the Data Access Layer as verifySession() (calling auth() inside a cache`d function) so the rest of the code stays provider-independent, as the Server Actions page does. The `signIn and signOut functions are called from Server Actions, as shown, or from Client Components with the helpers in next-auth/react.

Table 13. Observed behaviour of the Auth.js example (previous model, next start)
Request Anonymous User Admin

GET /dashboard

307 to /login?callbackUrl=…​ (Proxy, from authorized)

200

200

GET /admin

307 to /login?callbackUrl=…​

403 (forbidden())

200

GET /api/me

401

200

200

The credentials sign-in was a POST to /api/auth/callback/credentials with the token from GET /api/auth/csrf; it answered 302 and set authjs.session-token=<redacted> (HttpOnly, SameSite=Lax), and a wrong password answered 302 to /login?error=CredentialsSignin. GET /api/auth/session returned the typed session: the name, email, id and role, plus an expires timestamp. For the GitHub provider, POST /api/auth/signin/github answered a redirect to https://github.com/login/oauth/authorize with client_id taken from the environment variable, response_type, scope, the redirect_uri /api/auth/callback/github and a PKCE code_challenge and code_challenge_method; no GitHub account was involved. The protocol steps behind that redirect are explained in Authorization Code Flow and PKCE and OpenID Connect, the provider-side flow in Social Login and Federation.

Auth.js in the Pages Router

Auth.js can serve a Pages Router application, but the route handler must still be an App Router Route Handler under app/api/auth/[…​nextauth]/route.ts, so such a project becomes a hybrid. In a page, auth(ctx) inside getServerSideProps replaces getServerSession:

pages/legacy-dashboard.tsx
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { auth } from "@/auth";

export const getServerSideProps = (async (ctx) => {
  const session = await auth(ctx);
  if (!session?.user) {
    return { redirect: { destination: "/login", permanent: false } };
  }
  return { props: { name: session.user.name ?? "" } };
}) satisfies GetServerSideProps<{ name: string }>;

export default function LegacyDashboard({
  name,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return <h1>Welcome (Pages Router), {name}</h1>;
}

Two obstacles appeared when the example was built, and both are specific to this beta:

  • import "server-only" is an App Router feature: a module that imports it (the example’s user store did) fails the build with "You’re importing a module that depends on server-only …​ but you are using it in the Pages Router". Drop the import for modules shared with Pages Router pages.

  • The first build failed in "Collecting page data" with ERR_MODULE_NOT_FOUND for next/server imported from next-auth/lib/env.js. Adding transpilePackages: ["next-auth"] to next.config.ts fixed it. The App Router routes kept working with that setting.

With both changes, an anonymous GET /legacy-dashboard answered 307 to /login and a signed-in one 200. The Auth.js docs also carry a note that some server-side API methods for the Pages Router were still to be re-implemented, so check the behaviour you need before committing to it. Client-side useSession() needs <SessionProvider> in _app.tsx (or a client wrapper in the App Router); it is a presentation tool, not a protection (see Where the Books Are Outdated).

Other Providers: Auth0, Clerk and Better Auth

The official guide lists a dozen libraries and services (Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js, Ory, Stack Auth, Supabase, Stytch and WorkOS). Three of them are summarised below, each checked against its current documentation on 2026-10-03. What the examples did and did not exercise is stated for each, because a hosted service needs an account that this page does not create.

Table 14. Three alternatives at a glance
Auth0 (@auth0/nextjs-auth0 4.31.0) Clerk (@clerk/nextjs 7.9.10) Better Auth (better-auth 1.7.7)

Model

Hosted identity provider; the SDK handles the OIDC redirect flow and keeps the session in an encrypted cookie

Hosted users, sessions and prebuilt components

A library you run yourself, with your own database; Auth.js has joined the project

Where it hooks in

Auth0Client plus Proxy, which mounts /auth/* routes

clerkMiddleware() in Proxy, <ClerkProvider> in the layout, auth() on the server

A catch-all route handler plus auth.api.getSession() on the server

Secrets and variables

AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET, optional APP_BASE_URL

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY

BETTER_AUTH_SECRET, BETTER_AUTH_URL, plus provider credentials

What this page did

Built in a scratch app; sign-in not exercised (needs a real tenant)

Type-checked only (needs keys)

Built and run end to end against a local SQLite database

Auth0 (v4)

Version 4 of the Auth0 Next.js SDK is a rewrite around one Auth0Client, which is created once and used in Proxy and on the server. It needs a Regular Web Application in the Auth0 dashboard, with http://localhost:3000/auth/callback registered as an allowed callback URL and the site URL as an allowed logout URL. AUTH0_SECRET encrypts the session and transaction cookies; the SDK documents openssl rand -hex 32 for it.

lib/auth0.ts
import { Auth0Client } from "@auth0/nextjs-auth0/server";

// Reads AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_SECRET
// and APP_BASE_URL from the environment.
export const auth0 = new Auth0Client();
proxy.ts
import { auth0 } from "@/lib/auth0";

export async function proxy(request: Request) {
  // Mounts /auth/login, /auth/logout, /auth/callback and /auth/profile
  // and keeps the rolling session alive.
  return await auth0.middleware(request);
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
};
app/dashboard/page.tsx
import { auth0 } from "@/lib/auth0";

export default async function DashboardPage() {
  const session = await auth0.getSession();
  if (!session) {
    return (
      <main>
        <a href="/auth/login">Log in</a>
      </main>
    );
  }
  return (
    <main>
      <h1>Welcome, {session.user.name}</h1>
      <a href="/auth/logout">Log out</a>
    </main>
  );
}

Facts worth knowing, from the SDK’s own documentation:

  • The routes it mounts dropped the /api prefix: login is /auth/login, not /api/auth/login.

  • The Proxy file is proxy.ts on Next.js 16 and middleware.ts on 15 and below; it should run on all routes (except static files) because rolling sessions and other features depend on it. middleware.ts still works on 16 under the Edge runtime.

  • By default the middleware protects no route. Protect a page by calling getSession() there, as above, which is the same "close to the resource" rule as everywhere else on this page.

  • withPageAuthRequired and withApiAuthRequired were removed in v4; use getSession() (or the useUser() hook for display).

  • Link to /auth/login with a plain <a> (or the default <Link>, which the SDK answers with 204 for the prefetch); avoid prefetch={true} on that link.

The scratch build of this example compiled and the dashboard page rendered its signed-out branch (the login link). A request to /auth/login answered 500, as expected: with a fictitious domain the SDK cannot fetch the OIDC discovery document. The sign-in itself was not exercised, and the OIDC steps are explained in OpenID Connect.

Clerk

Clerk hosts the user database, sessions and the sign-in screens. The current quickstart creates proxy.ts (on Next.js 16; middleware.ts on 15 and below) with clerkMiddleware(), wraps the application in <ClerkProvider> inside <body>, and reads the session on the server with await auth() from @clerk/nextjs/server. It also offers npx -y clerk@latest init to set a project up. clerkMiddleware() protects nothing by itself: every route is public until you protect it.

clerk-snippets/proxy.ts
import { clerkMiddleware } from "@clerk/nextjs/server";

// Public by default: clerkMiddleware() protects nothing on its own.
export default clerkMiddleware();

export const config = {
  matcher: [
    // Skip Next.js internals and static files
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // Always run for API routes
    "/(api|trpc)(.*)",
  ],
};
app/clerk-dashboard/page.tsx
import { auth, currentUser } from "@clerk/nextjs/server";

export default async function DashboardPage() {
  // Redirects to sign-in when signed out (document requests)
  await auth.protect();
  const user = await currentUser();
  return <h1>Welcome, {user?.firstName}</h1>;
}
app/api/clerk/route.ts
import { auth } from "@clerk/nextjs/server";

export async function GET() {
  const { isAuthenticated, userId } = await auth();
  if (!isAuthenticated) {
    return new Response(null, { status: 401 });
  }
  return Response.json({ userId });
}
app/clerk-layout-snippet.tsx
import type { ReactNode } from "react";
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from "@clerk/nextjs";

export function Shell({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </ClerkProvider>
      </body>
    </html>
  );
}

Clerk’s current guidance is the same as the official Next.js guidance: middleware is not the best place to protect routes, so protect access as close as possible to the resource that reads or mutates data, in pages, layouts, Route Handlers and Server Actions. auth.protect() redirects an unauthenticated document request to sign-in and returns a 404 to an authenticated user who is not authorized; auth() with isAuthenticated gives full control of the response. The route-matching helper createRouteMatcher() is marked deprecated in Clerk’s reference (it logs a runtime warning), with a migration guide towards resource-based checks. These snippets were type-checked with tsc against @clerk/nextjs 7.9.10 but not run, because running them needs Clerk keys. Never put CLERK_SECRET_KEY in client code; only the publishable key has the NEXT_PUBLIC_ prefix.

Better Auth

Better Auth is a TypeScript library that runs in your own application and database. Its Next.js integration is a catch-all route handler plus the server-side auth.api object, whose endpoints are callable as functions. A database is required for most features (it has a stateless mode without one, which most plugins do not support).

npm install better-auth
# .env.local (never committed): BETTER_AUTH_SECRET (generate with: openssl rand -base64 32),
# BETTER_AUTH_URL, and the GitHub credentials named in lib/auth.ts
npx auth@latest migrate        # creates the user, session, account and verification tables
lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import Database from "better-sqlite3";

export const auth = betterAuth({
  database: new Database("./sqlite.db"),
  emailAndPassword: { enabled: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
  plugins: [nextCookies()], // keep this the last plugin
});
app/api/auth/[…​all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function proxy(request: NextRequest) {
  // Optimistic only: checks the cookie exists, not that it is valid
  const sessionCookie = getSessionCookie(request);
  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard"] };
app/dashboard/page.tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export default async function DashboardPage() {
  // Authoritative: validates the session against the database
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect("/login");
  return <h1>Welcome, {session.user.name}</h1>;
}

The nextCookies() plugin (keep it last in the list) makes Server Actions able to set the session cookie when they call functions such as signInEmail, which would otherwise not set cookies. A Server Component cannot set cookies, so the cookie cache is only refreshed when the client or a Server Action or Route Handler interacts with the server. In Proxy the integration’s own advice is the one from the official Next.js guide: check only that the session cookie exists with getSessionCookie() to redirect optimistically (its comment says this is not secure), and validate the session in each page or route; on Next.js 16 Proxy runs on Node.js, so a full auth.api.getSession() validation is also possible there.

Run on next start with SQLite, a test user was created with POST /api/auth/sign-up/email (the request needs an Origin header; the answer was 200, a better-auth.session_token=<redacted> cookie and a body with token and user). GET /api/auth/get-session returned the user and the session rows. GET /dashboard without a cookie was 307 to /login (Proxy), with the session cookie 200, and with a forged cookie value it passed the Proxy (the cookie exists) and was rejected by the page, which redirected to /login. That last case is the optimistic-versus-authoritative distinction again, in another library. POST /api/auth/sign-in/social for github answered 200 with the provider redirect URL.

Authentication with Cache Components

With cacheComponents: true the rules for reading a session change, because a session read is a request-time read and a request-time read cannot be part of the static shell. The official companion guide (updated 2026-08-25) gives the pattern; this section reproduces its steps with the Bookshelf’s own session module and runs them. The caching background is in Partial Prerendering and Cache Components and Caching. The examples below were built with CACHE_COMPONENTS=1 (the scratch next.config.ts reads that variable for the flag; in a real project the flag is simply cacheComponents: true).

Step 1: Read the Current User in a Private Cache

A plain use cache function cannot call cookies(), and neither can use cache: remote; the session helper reads the cookie deep inside its own code and compares an expiry with the current time, so there is nothing to lift out and pass in. use cache: private exists for this case: it may read cookies(), headers() and searchParams, keeps the result in the browser only, and never caches it on the server. redirect() throws to interrupt rendering, so it is not cached; only a resolved user is.

lib/auth.ts
import "server-only";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { COOKIE_NAME, decrypt } from "@/lib/session";
import { findUserById } from "@/lib/users";

export type CurrentUser = { id: string; name: string; role: "admin" | "user" };

// Reads cookies(), so it must be "use cache: private": the result may be
// kept in the browser's cache, never on the server.
export async function getCurrentUser(): Promise<CurrentUser> {
  "use cache: private";
  const session = await decrypt((await cookies()).get(COOKIE_NAME)?.value);
  if (!session?.userId) redirect("/login");
  const user = await findUserById(session.userId);
  if (!user) redirect("/login");
  return { id: user.id, name: user.name, role: user.role };
}

Step 2: Show the User Without Blocking the Page

A component that reads the session must sit behind a <Suspense> boundary; reading cookies() outside one is a build error with Cache Components. The boundary is also what keeps the rest of the page instant: whatever is outside it prerenders into the static shell, and only the section behind it waits for the request. Keep the session read out of a layout’s top level for the same reason, because a top-level await there holds {children} behind the request; push it into a nested component inside a boundary.

app/dashboard/page.tsx
import { Suspense } from "react";
import { getCurrentUser } from "@/lib/auth";
import { getNotes } from "@/lib/notes";
import { addNote } from "@/app/actions/notes";

export default function DashboardPage() {
  return (
    <main>
      {/* Static: prerendered into the shell */}
      <h1>Bookshelf dashboard</h1>
      <Suspense fallback={<p>Loading your dashboard...</p>}>
        <Greeting />
        <Notes />
      </Suspense>
    </main>
  );
}

async function Greeting() {
  const user = await getCurrentUser();
  return <p>Welcome, {user.name}</p>;
}

async function Notes() {
  const notes = await getNotes();
  return (
    <>
      <ul>
        {notes.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
      <form action={addNote}>
        <input name="note" />
        <button type="submit">Add note</button>
      </form>
    </>
  );
}
app/admin/page.tsx
import { Suspense } from "react";
import { forbidden } from "next/navigation";
import { verifySession } from "@/lib/dal";

export default function AdminPage() {
  return (
    <main>
      <h1>Admin</h1>
      <Suspense fallback={<p>Checking access...</p>}>
        <AdminPanel />
      </Suspense>
    </main>
  );
}

async function AdminPanel() {
  const session = await verifySession();
  if (session.role !== "admin") forbidden();
  return <p>Admin-only panel</p>;
}

The build route table marks both pages ◐ Partial Prerender (static shell plus streamed content). The observed behaviour is different from the previous model in one important way:

Table 15. The same checks under Cache Components
Request Status Notes

GET /dashboard, anonymous

307 to /login

From Proxy, before any rendering

GET /dashboard, signed in

200

The shell (heading and the loading fallback) is in the first chunk; the greeting streams in

GET /admin, signed in as a user

200

The "403 - Forbidden" UI and noindex rendered, and the admin panel did not leak, but the status is 200 because the check ran inside <Suspense> after the shell was sent

GET /admin, signed in as an admin

200

The admin panel

The 200 for a forbidden user is not a bug; it is the documented trade-off. A real 403 needs the check to run before streaming, and under Cache Components the official forbidden() page says to do that check in Proxy. Proxy can only see the cookie, so a Proxy that returns 403 for the /admin prefix must rely on the role hint in the signed cookie (an optimistic check) while the Data Access Layer keeps the authoritative one; or accept the 200 plus the interrupt UI, which is usually fine for a page the user is shown rather than an API. A Route Handler is unaffected: it returns a real 401 or 403.

Step 3: Share the User with Client Components

Read the session once and hand it to both Server and Client Components inside the same boundary. A Server Component can call getCurrentUser() directly; for a Client Component, create the promise in the Server Component inside the Suspense boundary (not at the top of a layout), pass it through a context provider, and unwrap it with React’s use() behind its own <Suspense>. Expose only a narrow { id, name }, not the raw session, and use taintUniqueValue for sensitive fields. The official guide lists a UserProvider and a useUser() hook for this; the pattern is the one in Server and Client Components (passing a promise as a prop).

Step 4: Cache Data Derived From the Session

Once you have the user, cache what you fetch for them in one of two ways. Passing the user id into a plain use cache function keeps the result on the server, keyed by the id (which becomes part of the cache key) and invalidatable with a tag. Reading inside a use cache: private scope keeps it in the browser only, which matters when requirements forbid storing certain data on the server even briefly.

lib/notes.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
import { getCurrentUser } from "@/lib/auth";

const notes = new Map<string, string[]>();
export let reads = 0; // counts real "database" reads (scratch only)

export async function saveNote(userId: string, note: string) {
  notes.set(userId, [...(notes.get(userId) ?? []), note]);
}

// Exported: resolves the user first, so a caller cannot pick another id.
export async function getNotes() {
  const user = await getCurrentUser();
  return getNotesByUserId(user.id);
}

// Not exported on purpose. The user id becomes part of the cache key.
async function getNotesByUserId(userId: string) {
  "use cache";
  cacheTag(`notes:${userId}`);
  cacheLife("minutes");
  reads++;
  console.log(`db read for ${userId} (#${reads})`);
  return notes.get(userId) ?? [];
}

Keep getNotesByUserId unexported, so that no caller can ask for another user’s notes with a different id; resolving the user inside the exported getter is what makes that safe. Cache keys and cacheTag values are stored in plain text, so key and tag on a stable identifier such as the user id, never on a token, a password or a raw email.

Step 5: Update Session-Derived Data

A Server Action that changes the data calls updateTag with the same tag, and re-reads the session itself so that it authorises itself rather than trusting the client:

app/actions/notes.ts
"use server";

import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { COOKIE_NAME, decrypt } from "@/lib/session";
import { cookies } from "next/headers";
import { saveNote } from "@/lib/notes";

export async function addNote(formData: FormData) {
  // Re-read the session: the action authorises itself.
  const session = await decrypt((await cookies()).get(COOKIE_NAME)?.value);
  if (!session?.userId) redirect("/login");

  const note = String(formData.get("note") ?? "").trim();
  if (note) {
    await saveNote(session.userId, note);
    updateTag(`notes:${session.userId}`);
  }
}

The server log counted "database" reads. Ada’s dashboard, requested twice, and Bob’s, requested once, produced two reads (one per user id); after Ada added a note through the action, her entry was invalidated and read again, Bob’s was not, and Bob did not see Ada’s note. An anonymous POST of the same action was answered 307 and wrote nothing.

Step 6: Make Authenticated Navigations Instant

A use cache: private scope uses the default cache profile (a five-minute stale) unless you set one, and a route that reads the session yields a per-session App Shell that is prefetched and cached per session, so navigations to it are already instant. Two things keep it that way: if you tune the lifetime with cacheLife, keep stale at 30 seconds or more (below that the scope drops out of prefetching), and a route that also depends on a params or searchParams value needs <Link prefetch={true}> on the links that point to it, together with Partial Prefetching (the partialPrefetching flag, or prefetch = "partial" on the segment). Each such link costs one server invocation, so a sidebar of many links should not all use it. The behaviour of prefetching is described in Linking and Navigation.

An existing application that enables Cache Components gets every session-reading route flagged by instant-navigation validation. The guide’s migration advice is to set export const instant = false on the page or layout so it keeps blocking on the server, and then adopt the patterns one route at a time.

Pitfalls

  • Authorizing in the client only. See Where the Books Are Outdated: hiding UI protects nothing; the endpoint must check.

  • Checking only in a layout or only in Proxy. See Why a Layout Alone Is Not Enough and Optimistic Checks in Proxy.

  • Trusting the role in the cookie. Re-read the user in the DAL; the cookie is a hint.

  • redirect() inside try/catch. redirect(), notFound(), forbidden() and unauthorized() work by throwing; catching the error cancels them. Call them last, outside try, or rethrow with unstable_rethrow.

  • Reading cookies() outside <Suspense> under Cache Components. It is a build error; push the read into a component behind a boundary. Reading cookies() inside a plain use cache function throws; use use cache: private.

  • Secrets in cache keys, tags, logs or client props. Arguments and tags are plain text; pass ids.

  • A wrong status code under streaming. A check inside <Suspense> yields 200 with the interrupt UI (see Authentication with Cache Components).

  • Forgetting AUTH_TRUST_HOST behind a proxy or on localhost with next start. Auth.js answers UntrustedHost.

  • secure: true in local development. A Secure cookie may be refused over plain HTTP; enable it in production.

  • Unauthenticated GET prefetches that have side effects. Proxy and pages run on prefetches; a link to a login route should not write a transaction cookie (the Auth0 SDK special-cases the default prefetch for this reason).

  • Sessions in the URL. Never put tokens in query strings; use cookies set on the server.

  • No expiry, no rotation. Give the cookie and the token an expiry; rotate SESSION_SECRET and AUTH_SECRET when a copy might have leaked (which invalidates existing sessions, a trade-off to plan for).

  • Testing only the happy path. A useful HTTP test includes the negative cases: no cookie, a tampered cookie, a valid cookie for a user without the role, and an action posted without a session. The sibling page on testing and the end-to-end chapter End-to-End Testing in Real Browsers show how to log in once and reuse the state; the OAuth side is in Testing and Debugging OAuth.

Where the Books Are Outdated

The two requester books (listed in the section bibliography; nothing of them is reproduced here) teach authentication on the Pages Router. Their authentication chapters share faults that this page corrects. A summary:

Table 16. Faults in the books' authentication examples and their corrections
Fault Correction on this page

Pages protected only by client-side hooks (useAuth, useSession); the API behind them unchecked

Authorize on the server, in the DAL, in every Server Action and Route Handler (The Data Access Layer, Protecting Pages, Layouts, Server Actions and Route Handlers); see the demonstration below

A hard-coded JWT secret and hard-coded credentials

The secret comes from process.env and is generated with openssl rand -base64 32 or npx auth secret; users and password hashes come from the data source, with scrypt or bcrypt (Stateless Sessions with a Signed Cookie)

No token expiry

setExpirationTime("7d") on the token and expires on the cookie; the Auth.js session has a maxAge

A session cookie without secure and sameSite (and not HttpOnly)

httpOnly, sameSite: "lax" and secure in production (see the options table in Stateless Sessions with a Signed Cookie)

An uncaught jwt.verify error: a tampered cookie becomes a 500

decrypt catches the failure and returns undefined; the observed result for a bad cookie is 307 or 401

NEXTAUTH_SECRET left out of the initial NextAuth set-up

AUTH_SECRET is mandatory in v5 and npx auth secret writes it

The client-only guard, demonstrated. The page below decides in the browser. It calls /api/me, shows "Please log in." if that fails, and otherwise fetches the orders. The orders endpoint, written the way such examples usually are, checks nothing:

app/client-guard/page.tsx
"use client";

import { useEffect, useState } from "react";

// Anti-pattern: authorisation decided in the browser.
export default function ClientGuardPage() {
  const [me, setMe] = useState<{ name: string } | null | undefined>(undefined);
  const [orders, setOrders] = useState<unknown[]>([]);

  useEffect(() => {
    fetch("/api/me").then((r) => (r.ok ? r.json() : null)).then(setMe);
    fetch("/api/orders-unprotected").then((r) => r.json()).then(setOrders);
  }, []);

  if (me === undefined) return <p>Loading...</p>;
  if (me === null) return <p>Please log in.</p>; // "protected" only in the UI
  return <pre>{JSON.stringify(orders)}</pre>;
}
app/api/orders-unprotected/route.ts
// The page that calls this hides the UI from signed-out users; the endpoint does not check anything.
export async function GET() {
  return Response.json([{ id: 1, bookId: "1", totalCents: 999 }]);
}

On next start, the page itself answered 200 to an anonymous request (its HTML is only the loading shell), and the data endpoint answered an anonymous curl:

curl -s -w ' %{http_code}\n' http://localhost:3801/api/orders-unprotected
[{"id":1,"bookId":"1","totalCents":999}] 200

Anyone who skips the page reads the data. The fix is the Route Handler pattern in Protecting Pages, Layouts, Server Actions and Route Handlers: the handler calls the DAL and answers 401.

Also outdated in the books:

  • NextAuth v4 set-up. pages/api/auth/[…​nextauth].ts, NextAuthOptions, getServerSession(authOptions), withAuth and next-auth/middleware, and the NEXTAUTH_URL and NEXTAUTH_SECRET variables. In v5 there is a root auth.ts, a universal auth(), AUTH_* variables, the authjs cookie prefix, and (on Next.js 16) proxy.ts. SessionProvider and useSession still exist for display purposes.

  • Hand-rolled JWT login routes in pages/api. The App Router equivalent is a Server Action with cookies(), or a library. A JWT used as a session cannot be revoked; the choice between it and a database session is in Session Management.

  • The Auth0 integration. Version 4 of the Auth0 SDK has different routes (/auth/login), a different set-up (one Auth0Client and Proxy) and no withPageAuthRequired.

  • middleware.ts as the place for auth. The file is proxy.ts in Next.js 16, and checks in it are optimistic only.

  • Auth checks written with getServerSideProps. They still work in the Pages Router (see the Auth.js section) but the App Router moves the check into the Data Access Layer.

What Changed Recently

Table 17. Authentication-related changes across Next.js and the ecosystem
Version Change

Next.js 13.4 (2023)

The App Router becomes stable; cookies() and headers() are available in Server Components and Route Handlers

Next.js 14 (2023-10-26)

Server Actions are stable; the guidance that every action is a public endpoint begins to matter

Next.js 15 (2024-10-21)

cookies(), headers() and params become asynchronous; code must await cookies()

Next.js 15.1 (2024-12-10)

forbidden(), unauthorized() and the authInterrupts flag are introduced (still experimental in 16.3.8)

Next.js 15.2 (2025-02-26)

The Node.js runtime for middleware appears, which is what lets full session libraries run there; the following month CVE-2025-29927 shows why middleware cannot be the only check (fixed in 15.2.3, 14.2.25, 13.5.9 and 12.3.5)

Next.js 16 (2025-10-21)

middleware is deprecated in favour of proxy.ts on the Node.js runtime; synchronous request APIs are removed; the cache-model options become cacheComponents

Next.js 16.3 (2026-08-03)

Instant Navigations (opt-in cacheComponents with partialPrefetching). The docs now carry a Cache Components companion to the authentication guide (page last updated 2026-08-25) built on use cache: private

Auth.js

v5 is still a beta (5.0.0-beta.32), v4 remains the npm latest; the project is now part of Better Auth, which handles security patches and recommends itself for new projects

Auth0 SDK 4.x

Rewrite: Auth0Client, mounted /auth/* routes, no withPageAuthRequired or withApiAuthRequired, proxy.ts on Next.js 16

Clerk 7.x

Quickstart creates proxy.ts on Next.js 16; createRouteMatcher() is deprecated in favour of resource-based checks

Better Auth 1.x

Official Next.js integration with toNextJsHandler, nextCookies() and getSessionCookie()