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:
| 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 |
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; |
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.
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.
| Place | Kind of check | What it is good for |
|---|---|---|
Proxy ( |
Optimistic: cookie only |
Cheap pre-filtering and redirects for the whole site, and protecting static routes that share data between users. |
Data Access Layer ( |
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, |
Server Action |
Authoritative, through the DAL |
Every action is a public POST endpoint; it authorises itself. |
Route Handler |
Authoritative, through the DAL |
Returns |
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. |
Source: Authentication, Data Security.
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.
| 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 |
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.
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;
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);
}
Source: Authentication: Stateless Sessions, cookies().
| Option | Why |
|---|---|
|
JavaScript on the page cannot read the cookie, which limits what an XSS bug can steal. |
|
Sent over HTTPS only. The example enables it when |
|
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). |
|
A session without an expiry never ends. The signed token carries its own expiry as well ( |
|
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.
"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");
}
"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:
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):
| Input | Result |
|---|---|
Correct email and password |
|
Correct email, wrong password |
|
Unknown email |
The same |
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.
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);
}
Source: Authentication: Database Sessions.
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.
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):
| Request | Status | Why |
|---|---|---|
|
|
Proxy: no valid signature |
|
|
|
|
|
Proxy: already signed in |
|
|
The matcher excludes |
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
admincannot outlive the database row that saysuser.
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 };
});
Source: Authentication: Creating a Data Access Layer, Data Security: Data Access Layer, React: cache.
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:
| Request | Status |
|---|---|
|
|
|
|
|
|
(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:
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>
);
}
Source: Authentication: Auth checks in page components, Authentication: Auth checks in leaf components.
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:
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>;
}
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>
</>
);
}
"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):
| Action | Caller | Observed |
|---|---|---|
|
anonymous |
The action executed (the server log printed |
|
signed in |
Executed, |
|
anonymous |
Not executed; |
|
signed in |
Executed as the signed-in user, |
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:
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" });
}
Source: Authentication: Route Handlers, route.js.
| Caller | Status | Body |
|---|---|---|
anonymous |
|
|
signed in as a user |
|
|
signed in as an admin |
|
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.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: process.env.CACHE_COMPONENTS === "1",
experimental: {
authInterrupts: true,
},
};
export default nextConfig;
Source: authInterrupts, forbidden, unauthorized.
The two UI files and a page that uses forbidden():
export default function Forbidden() {
return (
<main>
<h1>403 - Forbidden</h1>
<p>You do not have access to this page.</p>
</main>
);
}
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>
);
}
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>;
}
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>;
}
Source: forbidden.js, unauthorized.js.
| Request | Status | Page content |
|---|---|---|
|
|
"403 - Forbidden" and |
|
|
"401 - Unauthorized", with a link to the login page, and |
|
|
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 isnever), and atry/catcharound the call swallows the interrupt so no UI renders; useunstable_rethrowto 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;403as an unhandled rejection). -
Status codes depend on streaming. If the check runs inside a
<Suspense>boundary, the response has begun as200and the status cannot change; the interrupt UI still renders in place. To get a real403the 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
latestdist-tag ofnext-authon npm is 4.24.15. Version 5, the one with the universalauth()and theAUTH_*environment variables, is installed withnext-auth@betaand is at5.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.
| Area | v5 |
|---|---|
Server API |
One universal |
Configuration |
A root |
Environment |
All variables are prefixed |
Cookies |
The |
Next.js |
Requires Next.js 14 or later; the migration guide notes that |
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:
| Variable | Purpose |
|---|---|
|
Encrypts the JWT and other cookies. Required. |
|
The GitHub OAuth app credentials; inferred when you pass |
|
Set to |
|
The canonical URL; usually unnecessary because the host is detected from the request. |
The Configuration, the Route Handler and Proxy
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;
},
},
});
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
export { auth as proxy } from "@/auth";
export const config = {
matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"],
};
Source: Auth.js: Installation, Migrating to v5, Deployment.
What each piece does:
-
NextAuth({ providers, … })returnshandlers,auth,signInandsignOut. The route handler mountshandlersunder/api/auth/*(sign-in, callback, session, CSRF and providers endpoints). -
GitHubis passed without options: the client id and secret come fromAUTH_GITHUB_IDandAUTH_GITHUB_SECRET. The callback URL to register in GitHub ishttps://<your-host>/api/auth/callback/github. -
The
Credentialsprovider forwards a login form to yourauthorizefunction; returningnullmakes Auth.js fail the sign-in withCredentialsSignin. 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 anHttpOnlycookie. With an adapter the default becomes"database", where the cookie holds a session id. Thejwtandsessioncallbacks copy the user id and role into the token and then into the session, which thedeclare moduleblocks type. Theimport "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 runauth()on every matched request, which also keeps the session alive by refreshing its expiry. Theauthorizedcallback decides what an unauthenticated request may reach: returningfalseredirects 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.tsand the export isexport { auth as middleware }. -
A database adapter that cannot run on the Edge needs the configuration split in two (an
auth.config.tswithout the adapter, used by Proxy or middleware, and the fullauth.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()
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>
);
}
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>
);
}
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.
| Request | Anonymous | User | Admin |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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:
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_FOUNDfornext/serverimported fromnext-auth/lib/env.js. AddingtranspilePackages: ["next-auth"]tonext.config.tsfixed 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.
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 |
|
|
A catch-all route handler plus |
Secrets and variables |
|
|
|
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.
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();
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).*)"],
};
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>
);
}
Source: nextjs-auth0 README, V4 migration guide.
Facts worth knowing, from the SDK’s own documentation:
-
The routes it mounts dropped the
/apiprefix: login is/auth/login, not/api/auth/login. -
The Proxy file is
proxy.tson Next.js 16 andmiddleware.tson 15 and below; it should run on all routes (except static files) because rolling sessions and other features depend on it.middleware.tsstill 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. -
withPageAuthRequiredandwithApiAuthRequiredwere removed in v4; usegetSession()(or theuseUser()hook for display). -
Link to
/auth/loginwith a plain<a>(or the default<Link>, which the SDK answers with204for the prefetch); avoidprefetch={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.
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)(.*)",
],
};
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>;
}
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 });
}
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
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
});
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);
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"] };
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.
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.
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>
</>
);
}
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:
| Request | Status | Notes |
|---|---|---|
|
|
From Proxy, before any rendering |
|
|
The shell (heading and the loading fallback) is in the first chunk; the greeting streams in |
|
|
The "403 - Forbidden" UI and |
|
|
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.
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:
"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()insidetry/catch.redirect(),notFound(),forbidden()andunauthorized()work by throwing; catching the error cancels them. Call them last, outsidetry, or rethrow withunstable_rethrow. -
Reading
cookies()outside<Suspense>under Cache Components. It is a build error; push the read into a component behind a boundary. Readingcookies()inside a plainuse cachefunction throws; useuse 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>yields200with the interrupt UI (see Authentication with Cache Components). -
Forgetting
AUTH_TRUST_HOSTbehind a proxy or onlocalhostwithnext start. Auth.js answersUntrustedHost. -
secure: truein local development. ASecurecookie 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_SECRETandAUTH_SECRETwhen 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:
| Fault | Correction on this page |
|---|---|
Pages protected only by client-side hooks ( |
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 |
No token expiry |
|
A session cookie without |
|
An uncaught |
|
|
|
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:
"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>;
}
// 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),withAuthandnext-auth/middleware, and theNEXTAUTH_URLandNEXTAUTH_SECRETvariables. In v5 there is a rootauth.ts, a universalauth(),AUTH_*variables, theauthjscookie prefix, and (on Next.js 16)proxy.ts.SessionProvideranduseSessionstill exist for display purposes. -
Hand-rolled JWT login routes in
pages/api. The App Router equivalent is a Server Action withcookies(), 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 (oneAuth0Clientand Proxy) and nowithPageAuthRequired. -
middleware.tsas the place for auth. The file isproxy.tsin 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.
Source: Authentication: authorization, Data Security and route.js
What Changed Recently
| Version | Change |
|---|---|
Next.js 13.4 (2023) |
The App Router becomes stable; |
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) |
|
Next.js 15.1 (2024-12-10) |
|
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) |
|
Next.js 16.3 (2026-08-03) |
Instant Navigations (opt-in |
Auth.js |
v5 is still a beta ( |
Auth0 SDK 4.x |
Rewrite: |
Clerk 7.x |
Quickstart creates |
Better Auth 1.x |
Official Next.js integration with |