MENU
Session Management
Authentication proves who someone is at one moment — the login request. A session is how that identity is remembered on every request that follows, without asking the user to log in again each time. In a Next.js App Router app, sessions are almost always persisted in a cookie, because cookies are automatically sent by the browser with every request, including the very first server-rendered request for a page.
Cookie attributes that matter
Whatever the session actually contains, the cookie carrying it should be set with:
- httpOnly — prevents any client-side JavaScript from reading the cookie via document.cookie, which closes off the most common way a XSS vulnerability turns into full session theft.
- secure — the cookie is only sent over HTTPS, never in the clear.
- sameSite — set to lax (the sensible default for a session cookie) or strict to control whether the cookie is sent on cross-site requests. This is also your first line of defense against CSRF.
- maxAge or expires — bounds how long the session is valid before the browser discards the cookie outright, independent of any server-side expiry check.
JWT sessions vs. opaque, server-stored sessions
There are two common shapes for what actually goes inside the cookie:
- A JWT (or similarly signed/encrypted token): the cookie itself holds the session data — typically a user id, a role, and an expiry — sealed so it can't be read or tampered with by the client. Validating it is just a signature check, with no database round-trip, which makes it cheap to verify in Middleware on the Edge Runtime. The tradeoff is that a JWT can't be revoked before it expires without extra machinery (a denylist), since the server doesn't hold a canonical record of which sessions are still valid.
- An opaque session token: the cookie holds nothing but a random, unguessable id. The actual session data lives server-side, in a database or a store like Redis, keyed by that id. This costs a lookup on every request, but it means a session can be revoked instantly — delete the row and the token stops working — which matters for a "log out of all devices" or "ban this user" feature.
Many production setups use a hybrid: a short-lived JWT for fast, stateless checks (e.g. in Middleware), backed by a server-side session record that's consulted before any sensitive action.
Reading and writing session cookies
The cookies() function from next/headers is how you read and write cookies on the server. Its capabilities depend on where you call it: inside a Server Component you can only read cookies (the response has already started streaming, so there's nowhere to attach a Set-Cookie header); inside a Server Action or a Route Handler you can both read and write, because Next.js can still modify the outgoing response.
lib,session.ts:
import { cookies } from 'next/headers'
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.SESSION_SECRET)
const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days, in seconds
export async function createSession(userId: string) {
const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000)
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(expires)
.sign(secret)
;(await cookies()).set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
expires,
path: '/',
})
}
export async function decryptSession(token: string) {
try {
const { payload } = await jwtVerify(token, secret)
return payload as { userId: string }
} catch {
return null // missing, expired, or tampered with
}
}
export async function destroySession() {
(await cookies()).delete('session')
}app,api,session,refresh,route.ts:
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { decryptSession, createSession } from '@/lib/session'
export async function POST() {
const token = (await cookies()).get('session')?.value
const session = token ? await decryptSession(token) : null
if (!session) {
return NextResponse.json({ error: 'No active session.' }, { status: 401 })
}
await createSession(session.userId) // re-issues the cookie with a fresh expiry
return NextResponse.json({ ok: true })
}Whichever storage shape you pick, treat the session secret used to sign or encrypt the cookie the same way you'd treat a database password: kept in an environment variable, never committed, and rotated if it's ever exposed. Session management libraries like iron-session wrap this exact sealing/unsealing logic so you don't have to write it by hand.