Skip to content

Authentication Patterns

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.

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.

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:

app/lib/crypto.ts
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
}
}
app/lib/session.ts
// read the session cookie on the server
import { 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)
}
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).

OptionTypeWhat it protects against
httpOnlybooleanBlocks client-side JavaScript (document.cookie) from reading the cookie — stops session theft via an XSS payload
securebooleanCookie is only sent over HTTPS — stops it leaking on a plain-HTTP connection
sameSiteboolean | 'lax' | 'strict' | 'none'Controls whether the cookie is sent on cross-site requests — the main defense against CSRF
pathstring, default '/'Scopes the cookie to a URL path within the domain
expires / maxAgeDate / numberBounds how long a stolen cookie stays valid
domainstringWhich domain the cookie is available on
partitionedbooleanOpts into CHIPS partitioned storage for third-party contexts
// app/lib/session.ts — set a session cookie with the recommended attributes
import '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: '/',
})
}

There are two shapes a session can take, and the trade-off is simplicity versus control:

Stateless sessionDatabase session
Where the data livesEncrypted/signed directly in the cookieA row in your database; the cookie only holds the session ID
Reading a sessionDecrypt the cookie — no I/OLook up the session ID — one extra query (or a cache in front of it)
Revoking a session earlyHard — the cookie is self-contained and valid until it expiresEasy — delete the row and the session is dead immediately
Tracking active sessions / “log out everywhere”Not possible without extra bookkeepingNatural — query all rows for a user
Good forSimple apps, low session-management overheadApps that need revocation, device tracking, or session auditing
// app/lib/session.ts — stateless: the whole session lives in the signed cookie
import '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 id
import { 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: '/' })
}

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 only
import { 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.

app/lib/dal.ts
// verify the session close to the data
import '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.

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:

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
authInterrupts: true,
},
}
export default nextConfig
  • unauthorized() — throws, renders the nearest unauthorized.tsx, and returns a real 401. Use it for “you are not signed in.”
  • forbidden() — throws, renders the nearest forbidden.tsx, and returns a real 403. Use it for “you’re signed in, but this isn’t yours.”
app/admin/page.tsx
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() throws
export 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.

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.

What you getBenefitCost
Stateless sessionNo database round-trip to readCannot be revoked early without extra state
Database sessionInstant revocation, “log out everywhere”An extra lookup (or cache) on every session read
Optimistic check in proxyFast, centralized, redirects obvious cases earlyNot authoritative — can be bypassed or skipped by matcher/prefetch
verifySession() in a DALThe one place that’s actually authoritativeMust be called explicitly everywhere protected data is touched
forbidden() / unauthorized()Real HTTP status + dedicated UI, one throwStill experimental — needs authInterrupts, and a stray try/catch silently swallows it
  • 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() and unauthorized() are stable, general-purpose APIs. As of 16.3.6 they are still experimental and require experimental.authInterrupts: true — omitting the flag fails the build with an explicit error, not a silent fallback.
  • Wrapping forbidden()/unauthorized() in try/catch is just defensive code. Both work by throwing; a surrounding try/catch swallows 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"]
Optimistic gate in proxy, real check in the DAL

When a generated snippet handles auth, check for these before trusting it:

  • Does it use forbidden() / unauthorized() without experimental.authInterrupts: true in next.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 a try/catch? All three work by throwing — a surrounding try/catch silently swallows the interrupt.
  • Does cookies().set('session', value) skip the options object entirely? A cookie with no httpOnly/secure/sameSite is 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 data skips 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

app/admin/page.tsx
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.

How do you read the session cookie on the server?
What is the key difference between authentication and authorization?
What happens when this route is built in Next.js 16.3.6?
// next.config.ts
const nextConfig = {}
// app/admin/page.tsx
import { forbidden } from 'next/navigation'
forbidden()
Why is a database session easier to revoke than a stateless (JWT-style) session?
What is wrong with this code?
export async function AdminPage() {
  const session = await verifySession()
  try {
    if (session.role !== 'admin') forbidden()
  } catch {}
  return <Dashboard />
}
Which cookie option is the primary defense against CSRF?