Authentication Patterns
The idea in one sentence
Section titled “The idea in one sentence”Do a lightweight optimistic check in proxy to redirect obviously-unauthed requests, but do the REAL authorization close to the data — in a Data Access Layer that verifies the session before returning anything.
Authentication vs authorization
Section titled “Authentication vs authorization”These two words get used interchangeably, but they answer different questions:
- Authentication (authn): who is this? Verifying the user is who they claim to be — a password check, a valid session cookie, a verified token.
- Authorization (authz): what can they do? Given a known, authenticated identity, deciding whether they can access this route, this record, or perform this mutation.
A request can be authenticated (a valid session exists) and still be unauthorized (that user just isn’t an admin). Keeping the two separate matters for where the check lives: authentication commonly happens once per request (resolve the session), while authorization happens per-resource, right before you touch the data that resource guards.
Sessions and cookies
Section titled “Sessions and cookies”Authentication usually rides on a session stored in an HTTP-only cookie. On the server you read that cookie with cookies() from next/headers.
The session helpers below sign and verify the cookie value through one small file:
import 'server-only'import { SignJWT, jwtVerify } from 'jose'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
export type SessionPayload = { userId: string; expiresAt: Date }
export async function encrypt(payload: SessionPayload) { return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('7d') .sign(encodedKey)}
export async function decrypt(token: string): Promise<SessionPayload | null> { try { const { payload } = await jwtVerify(token, encodedKey, { algorithms: ['HS256'] }) return payload as SessionPayload } catch { return null }}// read the session cookie on the serverimport { cookies } from 'next/headers'import { decrypt } from '@/app/lib/crypto'
export async function getSession() { const cookie = (await cookies()).get('session')?.value if (!cookie) return null return decrypt(cookie)}Cookie attributes, and why each one matters
Section titled “Cookie attributes, and why each one matters”Setting a session cookie is not just cookies().set('session', value) — every option in the table below closes off a real attack surface. cookies().set(name, value, options) is only callable from a Server Function or Route Handler (HTTP forbids setting cookies once streaming has started).
| Option | Type | What it protects against |
|---|---|---|
httpOnly | boolean | Blocks client-side JavaScript (document.cookie) from reading the cookie — stops session theft via an XSS payload |
secure | boolean | Cookie is only sent over HTTPS — stops it leaking on a plain-HTTP connection |
sameSite | boolean | 'lax' | 'strict' | 'none' | Controls whether the cookie is sent on cross-site requests — the main defense against CSRF |
path | string, default '/' | Scopes the cookie to a URL path within the domain |
expires / maxAge | Date / number | Bounds how long a stolen cookie stays valid |
domain | string | Which domain the cookie is available on |
partitioned | boolean | Opts into CHIPS partitioned storage for third-party contexts |
// app/lib/session.ts — set a session cookie with the recommended attributesimport 'server-only'import { cookies } from 'next/headers'import { encrypt } from '@/app/lib/crypto'
export async function createSession(userId: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const session = await encrypt({ userId, expiresAt }) const cookieStore = await cookies()
cookieStore.set('session', session, { httpOnly: true, secure: true, expires: expiresAt, sameSite: 'lax', path: '/', })}Stateless vs database sessions
Section titled “Stateless vs database sessions”There are two shapes a session can take, and the trade-off is simplicity versus control:
| Stateless session | Database session | |
|---|---|---|
| Where the data lives | Encrypted/signed directly in the cookie | A row in your database; the cookie only holds the session ID |
| Reading a session | Decrypt the cookie — no I/O | Look up the session ID — one extra query (or a cache in front of it) |
| Revoking a session early | Hard — the cookie is self-contained and valid until it expires | Easy — delete the row and the session is dead immediately |
| Tracking active sessions / “log out everywhere” | Not possible without extra bookkeeping | Natural — query all rows for a user |
| Good for | Simple apps, low session-management overhead | Apps that need revocation, device tracking, or session auditing |
// app/lib/session.ts — stateless: the whole session lives in the signed cookieimport 'server-only'import { SignJWT, jwtVerify } from 'jose'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
export async function encrypt(payload: { userId: string; expiresAt: Date }) { return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('7d') .sign(encodedKey)}// app/lib/session.ts — database: the cookie only carries an opaque session idimport { cookies } from 'next/headers'import { db } from '@/app/lib/db'
export async function createSession(userId: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const [{ id }] = await db.insert(sessions).values({ userId, expiresAt }).returning({ id: sessions.id })
const cookieStore = await cookies() cookieStore.set('session', id, { httpOnly: true, secure: true, expires: expiresAt, sameSite: 'lax', path: '/' })}Optimistic check in proxy
Section titled “Optimistic check in proxy”Proxy (proxy.ts) is the right place for a cheap, optimistic gate: if there is clearly no session, redirect before rendering. This is fast, but it only redirects — it must not be your only line of defense. Because proxy runs on every route, including prefetched routes, only read the session from the cookie here — never do a database lookup in proxy.
// proxy.ts — optimistic redirect onlyimport { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'
const protectedRoutes = ['/dashboard']
export function proxy(request: NextRequest) { const path = request.nextUrl.pathname const hasSession = request.cookies.has('session') if (protectedRoutes.some((p) => path.startsWith(p)) && !hasSession) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next()}Real authorization lives in a Data Access Layer
Section titled “Real authorization lives in a Data Access Layer”Proxy alone is not sufficient. A matcher that excludes a path also skips the Server Action calls on that path, layouts do not re-run on every navigation, and a check that lives only in front of the app can be bypassed outright — CVE-2025-29927 let attackers skip middleware entirely with a crafted x-middleware-subrequest header (fixed in Next.js 15.2.3). The fix is a Data Access Layer (DAL): a single place that verifies the session right before it returns data. Wrap it in React cache so it runs once per request.
// verify the session close to the dataimport 'server-only'import { cache } from 'react'import { redirect } from 'next/navigation'import { getSession } from '@/app/lib/session'
export const verifySession = cache(async () => { const session = await getSession() if (!session?.userId) { redirect('/login') } return { isAuth: true, userId: session.userId }})Then every page, Server Action, or route handler that touches protected data calls verifySession() first. For real projects, libraries like NextAuth (Auth.js), Clerk, or Better Auth handle sessions, providers, and tokens for you — but the DAL pattern for authorization still applies on top of them.
forbidden() and unauthorized()
Section titled “forbidden() and unauthorized()”Beyond a plain redirect('/login'), Next.js has two purpose-built functions for authorization failures — and as of 16.3.6 both are still experimental, gated behind experimental.authInterrupts in next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { experimental: { authInterrupts: true, },}
export default nextConfigunauthorized()— throws, renders the nearestunauthorized.tsx, and returns a real401. Use it for “you are not signed in.”forbidden()— throws, renders the nearestforbidden.tsx, and returns a real403. Use it for “you’re signed in, but this isn’t yours.”
import { forbidden } from 'next/navigation'
async function verifySession() { // Real apps call the DAL from the previous section; inlined here so the // snippet has no cross-file dependency. return { role: 'user' as string }}
export default async function AdminPage() { const session = await verifySession() if (session.role !== 'admin') { forbidden() } return <div>Admin Dashboard</div>}// app/admin/forbidden.tsx — rendered in place of the page when forbidden() throwsexport default function Forbidden() { return <h1>403 - Forbidden</h1>}Proving both halves of this in the probe: calling forbidden() with the flag off fails the build outright —
Error: `forbidden()` is experimental and only allowed to be enabled when `experimental.authInterrupts` is enabled.— and with the flag on, the same route returns the real status code with the custom UI:
HTTP 403<title>403: This page could not be accessed.</title><meta name="robots" content="noindex"/><h1>403 - Forbidden</h1>Both functions throw (their TypeScript return type is never), which is exactly why a try/catch around the call is a real bug, not just a style nit: it swallows the throw, and no forbidden/unauthorized UI ever renders. If the call sits behind a promise that is never awaited, the same thing happens — the rejection has nothing to catch it.
Mental Model
Section titled “Mental Model”Think of authentication as the passport check at the border — it only asks “does this person exist and are they who they say they are,” and it happens once. Authorization is the security guard inside the building who checks per-door whether this already-verified person is allowed through this specific door. Proxy is the greeter at the front entrance doing a glance at the passport photo; the DAL is the guard at every door who actually checks the badge against the access list before it opens.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Stateless session | No database round-trip to read | Cannot be revoked early without extra state |
| Database session | Instant revocation, “log out everywhere” | An extra lookup (or cache) on every session read |
| Optimistic check in proxy | Fast, centralized, redirects obvious cases early | Not authoritative — can be bypassed or skipped by matcher/prefetch |
verifySession() in a DAL | The one place that’s actually authoritative | Must be called explicitly everywhere protected data is touched |
forbidden() / unauthorized() | Real HTTP status + dedicated UI, one throw | Still experimental — needs authInterrupts, and a stray try/catch silently swallows it |
Common Misconceptions
Section titled “Common Misconceptions”- Authentication and authorization are the same check. Authentication confirms identity; authorization decides what that identity can do. A logged-in user can still be unauthorized for a specific resource.
- A stateless (JWT-style) session can be revoked by just “deleting” it. It can’t — the cookie is self-contained and stays valid until it expires, unless you add a server-side blocklist (which brings back the database round-trip you were avoiding).
forbidden()andunauthorized()are stable, general-purpose APIs. As of 16.3.6 they are still experimental and requireexperimental.authInterrupts: true— omitting the flag fails the build with an explicit error, not a silent fallback.- Wrapping
forbidden()/unauthorized()intry/catchis just defensive code. Both work by throwing; a surroundingtry/catchswallows the interrupt and no forbidden/unauthorized UI ever renders. - A proxy-level redirect is authorization. It’s an optimistic hint at best — proxy can be skipped by a matcher gap, doesn’t re-run on client navigations the way a layout might assume, and (per CVE-2025-29927) has historically been bypassable outright.
graph TD A["Request"] --> B["proxy: optimistic redirect (authn only)"] B --> C["Page / Server Action"] C --> D["verifySession() in DAL"] D -->|no session| E["redirect to /login"] D -->|valid, wrong role| F["forbidden()"] D -->|valid| G["Return protected data"]
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet handles auth, check for these before trusting it:
- Does it use
forbidden()/unauthorized()withoutexperimental.authInterrupts: trueinnext.config.ts? That fails the build with an explicit error in 16.3.6 — not a silent no-op. - Is
forbidden()/unauthorized()/redirect()ever wrapped in atry/catch? All three work by throwing — a surroundingtry/catchsilently swallows the interrupt. - Does
cookies().set('session', value)skip the options object entirely? A cookie with nohttpOnly/secure/sameSiteis readable by any injected script and sendable cross-site. - Does the code treat a proxy-level redirect as the authorization check, with no corresponding
verifySession()call in the DAL, page, Server Action, or Route Handler that actually touches the data? - Does it confuse “logged in” with “allowed”? A check like
if (session) return dataskips authorization entirely — it only confirms authentication. - For a stateless (JWT) session, does the code assume a “delete session” function exists server-side? Revocation isn’t possible without a database session or a blocklist.
🐛 Spot the bug
import { verifySession } from '@/app/lib/dal'import { forbidden } from 'next/navigation'
export default async function AdminPage() {const session = await verifySession()
try { if (session.role !== 'admin') { forbidden() }} catch { // ignore and keep rendering}
return <AdminDashboard />}Show the answer
forbidden() works by throwing — its return type is never. Wrapping it in try/catch catches that throw and swallows it, so the catch block’s comment (“ignore and keep rendering”) is exactly what happens: a non-admin user falls through to <AdminDashboard /> anyway. The fix is to never wrap forbidden()/unauthorized()/redirect() in a try/catch — if something around the call truly needs one, use unstable_rethrow inside the catch to let the interrupt continue propagating.