#Authentication patterns in the App Router
AddedAuthentication in a Next.js app is three separate jobs, and most bugs come from mixing them up. Authentication proves who someone is (email and password, a passkey, "Sign in with Google"). Session management remembers the answer across requests, almost always with a cookie. Authorisation decides, on every request, whether this person may see this data or run this mutation. Next.js gives you no auth system of its own: it gives you the places to put each job. A Server Action or Route Handler checks credentials and sets the cookie; cookies() reads it on the server; the Data Access Layer (DAL) turns it into "who is calling" and "are they allowed".
A good mental model is a hotel key card. The front desk (your login action) checks your passport once and hands you a card. The card is encoded so you can't forge one for the penthouse (the cookie is signed). Every door has its own card reader (every page, Server Action and Route Handler checks the session itself). There's also a guard in the lobby who glances at cards and turns away people with no card at all (proxy): useful, cheap, and not a lock on any door.
There are two ways to store the session, and interviewers like to hear you compare them:
| Stateless session (signed or encrypted cookie, JWT) | Database session | |
|---|---|---|
| What the cookie holds | The session data itself (user id, role, expiry), signed so it can't be edited | A random session id; the data lives in a table |
| Checking it | Verify the signature with the secret: no database call | Look the id up on every request (cache it per render) |
| Logging out everywhere, banning a user | Hard: a valid token stays valid until it expires, unless you add a denylist | Easy: delete the row |
| Size | Grows with the payload (cookies are limited to about 4 KB) | Tiny |
| Typical tools | jose (JWT), iron-session (encrypted), your own HMAC as below | Auth.js database adapter, Better Auth, Lucia-style tables |
A JWT is just one format for a stateless token. Put in a cookie with HttpOnly, it behaves like the signed cookie below. Put in localStorage and sent in an Authorization header, it can be read by any script that runs on the page, so one XSS bug can steal it. That's why Next.js apps almost always keep the session in an HttpOnly cookie, even when the token inside is a JWT.
The bundled docs recommend an auth library for real apps (Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js/Auth.js, Ory, Stack Auth, Supabase, Stytch and WorkOS are listed) or a session library (iron-session, jose). To understand what those libraries do, here's a complete session built from Node's own Web Crypto, with no packages: a signed cookie, a login action, and the login page.
// Signs and checks the session token with Web Crypto (HMAC-SHA256).
// No Next.js imports, so proxy.ts and the server code can both use it.
export type Session = { userId: string; role: "member" | "admin"; expiresAt: number };
const encoder = new TextEncoder();
function getKey() {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
export async function signSession(session: Session): Promise<string> {
const body = Buffer.from(JSON.stringify(session)).toString("base64url");
const mac = await crypto.subtle.sign("HMAC", await getKey(), encoder.encode(body));
return `${body}.${Buffer.from(mac).toString("base64url")}`;
}
export async function verifySessionToken(token: string | undefined): Promise<Session | null> {
if (!token) return null;
const [body, mac] = token.split(".");
if (!body || !mac) return null;
const valid = await crypto.subtle.verify("HMAC", await getKey(), Buffer.from(mac, "base64url"), encoder.encode(body));
if (!valid) return null; // edited or forged: the signature no longer matches
const session = JSON.parse(Buffer.from(body, "base64url").toString()) as Session;
return session.expiresAt > Date.now() ? session : null;
}getKey()turnsSESSION_SECRETinto an HMAC-SHA256 key withcrypto.subtle.importKey. The secret comes from.env(only server code can read it, see Environment variables and secrets), and the length check makes a missing or weak secret fail loudly instead of signing withundefined.signSession({ userId: "u1", role: "member", expiresAt })serialises the object to JSON and base64url-encodes it: that'sbody. It then computes an HMAC ofbodywith the key: that'smac. The token isbody.mac, the same two-part idea as a JWT without the header.verifySessionToken(token)splits on the dot and asks Web Crypto whethermacis the correct HMAC ofbody. Only someone holding the secret can produce a matchingmac, so if a user edits"role":"member"to"role":"admin"the body changes, the signature no longer matches, and the function returnsnull.- Only after the signature checks out does it parse the JSON and check
expiresAt > Date.now(). The expiry is inside the signed body, so the user can't extend it either; the cookie's ownExpiresattribute is just a hint to the browser. - The file imports nothing from Next.js on purpose:
proxy.ts(which readsrequest.cookies) and the server code (which usescookies()) both call these two functions. What this demonstrates: a "stateless session" is nothing magic, just data plus a signature the server can check without a database.
The secret, the cookie helpers, a stand-in database, the login action and the login page complete the sign-in flow:
# A placeholder for local development only. Real secrets live in .env.local (git-ignored) or the host's settings.
SESSION_SECRET=dev-only-secret-replace-me-0123456789abcdefimport "server-only";
import { cookies } from "next/headers";
import { signSession, verifySessionToken, type Session } from "./session-token";
const COOKIE = "session";
const WEEK = 7 * 24 * 60 * 60 * 1000;
export async function createSession(userId: string, role: Session["role"]) {
const expiresAt = Date.now() + WEEK;
const token = await signSession({ userId, role, expiresAt });
(await cookies()).set(COOKIE, token, {
httpOnly: true, // page JavaScript can't read it (XSS can't steal it)
secure: true, // HTTPS only (browsers treat http://localhost as secure)
sameSite: "lax", // not sent on cross-site POSTs
path: "/",
expires: new Date(expiresAt),
});
}
export async function readSession() {
return verifySessionToken((await cookies()).get(COOKIE)?.value);
}
export async function deleteSession() {
(await cookies()).delete(COOKIE);
}import "server-only"makes the build fail if a Client Component ever imports this file (the error is shown in Environment variables and secrets). Session code that reads the secret must never reach a browser bundle.createSession(userId, role)signs a token that expires in seven days and stores it with(await cookies()).set(...). In Next.js 15 and 16cookies()returns a Promise; in 13 and 14 it was synchronous, and 16 removed the synchronous compatibility completely.- The options are the ones the bundled docs recommend:
httpOnlyso page scripts can't read it,secureso it only travels over HTTPS,sameSite: "lax"so the browser doesn't attach it to cross-site POSTs (a big part of CSRF protection),path: "/", and an expiry. cookies().setonly works where Next.js can still add aSet-Cookieheader: in a Server Action or a Route Handler. Calling it while rendering a page throws, which is why login and logout are actions.readSession()is the one function the rest of the app uses to answer "is there a valid session?": it returns the verified payload ornull, never a half-trusted value.
import "server-only";
import { scryptSync, timingSafeEqual } from "node:crypto";
// An in-memory stand-in for a database. A fixed salt keeps the demo short; real code stores a random salt per user.
const hash = (password: string) => scryptSync(password, "demo-salt", 32);
const users = [
{ id: "u1", email: "alice@example.com", name: "Alice", role: "member" as const, passwordHash: hash("alice-pass") },
{ id: "u2", email: "bob@example.com", name: "Bob", role: "admin" as const, passwordHash: hash("bob-pass") },
];
// On globalThis because pages and Route Handlers are bundled separately and would each get their own copy.
const store = globalThis as { demoNotes?: { id: string; ownerId: string; text: string }[] };
export const notes = (store.demoNotes ??= [
{ id: "n1", ownerId: "u1", text: "Alice: buy milk" },
{ id: "n2", ownerId: "u2", text: "Bob: rotate the API keys" },
]);
export function findUserByCredentials(email: string, password: string) {
const user = users.find((u) => u.email === email);
const attempt = hash(password);
return user && timingSafeEqual(user.passwordHash, attempt) ? user : null;
}
export function findUserById(id: string) {
return users.find((u) => u.id === id) ?? null;
}"use server";
import { redirect } from "next/navigation";
import { createSession, deleteSession } from "@/lib/session";
import { findUserByCredentials } from "@/lib/db";
export async function login(formData: FormData) {
const user = findUserByCredentials(String(formData.get("email")), String(formData.get("password")));
if (!user) redirect("/login?error=1");
await createSession(user.id, user.role);
redirect("/dashboard");
}
export async function logout() {
await deleteSession();
redirect("/login");
}import { Suspense } from "react";
import { login } from "@/app/actions/auth";
export default function LoginPage({ searchParams }: PageProps<"/login">) {
return (
<main>
<h1>Sign in</h1>
<Suspense>
<LoginError searchParams={searchParams} />
</Suspense>
<form action={login}>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button>Sign in</button>
</form>
</main>
);
}
async function LoginError({ searchParams }: { searchParams: PageProps<"/login">["searchParams"] }) {
const { error } = await searchParams;
return error ? <p role="alert">Wrong email or password.</p> : null;
}- The login page renders a plain
<form action={login}>. Becauseloginis a Server Action, Next.js renders the form with a hidden$ACTION_ID_…input, so it works even before JavaScript loads (progressive enhancement); with JavaScript it posts withfetchinstead. login(formData)runs on the server.findUserByCredentialshashes the submitted password withscryptand compares it withtimingSafeEqual, which takes the same time whether the first or the last byte differs, so response timing doesn't leak how close a guess was.- On a wrong password it calls
redirect("/login?error=1").redirectthrows a special error that Next.js turns into a redirect response, so nothing after it runs. The page readssearchParamsinside its own<Suspense>boundary because, with Cache Components, request data outside a boundary is a build error. - On success it calls
createSession(user.id, user.role), which queues theSet-Cookieheader, thenredirect("/dashboard"). The redirect and the cookie go back in the same response. logoutdeletes the cookie and redirects. With a stateless session that's all logout can do: a copy of the old token, if someone stole it, stays valid untilexpiresAt. That's the trade-off from the table above.
Here's what the browser actually receives. I ran next build && next start -p 4509 and posted the form with curl, the same request a browser without JavaScript sends (the action id came from the hidden input in the login page's HTML and changes with every build; date and connection headers trimmed):
HTTP/1.1 303 See Other
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Set-Cookie: session=eyJ1c2VySWQiOiJ1MSIsInJvbGUiOiJtZW1iZXIiLCJleHBpcmVzQXQiOjE3OTM1NTcwOTgxMDB9.RYsWeT-x9Ag4pIqsHQNxCSB05jEtPC3TbFeM2JmA3yg; Path=/; Expires=Sun, 01 Nov 2026 18:18:18 GMT; Secure; HttpOnly; SameSite=lax
Location: /dashboard- The action answered with
303 See Other, the status Next.js uses for a redirect after a POST, so the browser follows it with a GET to/dashboardinstead of re-posting the form. Set-Cookiecarries the token with exactly the attributescreateSessionasked for. With a wrong password the same request came back303withLocation: /login?error=1and noSet-Cookieat all.- The part before the dot is only base64url, not encryption. Decoding it with
Buffer.from(body, "base64url")printed{"userId":"u1","role":"member","expiresAt":1793557098100}. Anyone holding the cookie can read it; nobody without the secret can change it. - I then edited the decoded body to
"role":"admin", re-encoded it and kept the old signature.GET /api/noteswith that cookie answered401 Unauthorizedwith{"error":"Not signed in"}, andGET /dashboardwith it got proxy's307to/login. The genuine cookie got200and[{"id":"n1","text":"Alice: buy milk"}]. - In headless Chrome, after signing in through the real form,
document.cookiewas"": the session cookie is invisible to page scripts because ofHttpOnly, which is what keeps an XSS bug from stealing it.
With cacheComponents: true (the default for new 16.x apps), a session read is request-time data: it can't be prerendered into the static shell, so anything that reads it sits behind a <Suspense> boundary and streams in. A few rules from the bundled "Authentication with Cache Components" guide are worth knowing by heart:
- A plain
'use cache'function can't callcookies()(it throws). Read the session outside, then pass a stable value such as the user id into the cached function, where it becomes part of the cache key. 'use cache: private'can readcookies()andheaders(); its result is cached only in the browser, never on the server, which suits "the current user" (see'use cache: private'and'use cache: remote').- Cache keys and
cacheTagvalues are stored in plain text. Key and tag on a user id, never on a token, email or password. - Don't
awaitthe session at the top of a layout: it holds the whole segment, including{children}, behind the request. Push the read into a small component inside a boundary (a user menu, a badge). - Client Components can't import the DAL. A Server Component reads the user and passes a narrow object (or a Promise for
use()) down.
Database sessions: the same app with a revocable session
The signed cookie above can't be revoked. To switch to database sessions you change only the session module; the DAL, actions and pages stay the same, which is the point of having one readSession():
- Login inserts a row
{ id: randomId, userId, expiresAt }into asessionstable and puts the random id in the cookie. The id must come from a cryptographic generator (crypto.randomUUID()or 32 random bytes), never from an auto-increment counter. readSession()looks the id up and returnsnullif the row is missing or expired. Wrap the lookup in React'scache()so a page that calls it from several components hits the database once per render.- Logout deletes the row, so a stolen copy of the cookie stops working immediately. "Log out of all devices" is
DELETE FROM sessions WHERE user_id = ?, and banning a user takes effect on their next request. - Proxy can no longer verify the session without a database call, which the docs say to avoid there. The bundled guide suggests encrypting the session id in the cookie anyway, so proxy can at least check that the cookie is well-formed and not expired before the real lookup happens in the DAL.
- Sliding expiry (extend the session while the user is active) means updating
expiresAtand re-setting the cookie, which can only happen in a Server Action or Route Handler, not while rendering a page.
A library such as Auth.js, Better Auth or Clerk fills the same slots as the hand-written code above, plus the parts that are easy to get wrong: OAuth and OpenID Connect flows (state and PKCE checks, token exchange), account linking, email verification and password reset tokens, multi-factor authentication, rate limiting on the login endpoint, and key rotation. In App Router terms you usually get a server helper that plays the role of readSession(), a helper for proxy, and a way to sign in and out from actions. What no library can do for you is the second half of Authorisation close to the data: deciding whether this user may touch this row. That logic stays in your DAL.