open atlas
↑ Back to track
Next.js, zero to senior NEXT · 04 · 01

Sessions vs JWT: revocation, rotation, and where the state lives on serverless

Opaque session ids in an HttpOnly __Host- cookie plus a KV/DB store buy instant revocation at ~1-2 ms per request; JWTs verify locally but cannot be recalled before exp. Rotation, cookie attributes, and where the store lives on serverless decide which one you can operate.

NEXT Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior

A fintech team sails through SOC 2 prep until the auditor asks one question: “A laptop with an active session is stolen at 14:00. Show me how you terminate that session.” They can’t. Auth is a JWT with a 24-hour exp, verified by signature alone — no request ever consults the server about whether the token is still welcome. Support’s “log out all devices” button deletes refresh tokens, so the thief can’t mint a new token — but the one on the stolen laptop stays valid until tomorrow, because that is what stateless means: the server kept no state it could delete. The team ships the obvious mitigation — access tokens now live 5 minutes, refresh goes through the database — and an engineer asks the uncomfortable retro question: if every client hits the database every five minutes anyway, what exactly is the statelessness buying us? That question is this lesson.

A session is a pointer; a JWT is a copy

Before you pick an auth strategy, ask yourself one question: what happens when you need to terminate an active session right now, not in an hour? The answer separates sessions from JWTs more cleanly than any performance benchmark.

A stateful session stores nothing meaningful in the browser. The cookie carries an opaque random token — 128 bits of entropy minimum, in practice 256 from crypto.randomBytes(32) — and the server keeps a record mapping that token to the user id, expiry, and metadata. Every authenticated request does one store lookup. The payoff is operational control: revocation is deleting a row, effective on the very next request. “Log out everywhere” is deleting all rows for a user. Rotation — issuing a fresh token and invalidating the old one — happens on login and on privilege escalation, which is the standard defense against session fixation: an attacker who planted a token before login holds a pointer to nothing after it.

A JWT inverts this: the claims travel with the request — header.payload.signature — and verification is local math: check the signature, check exp, done. No store, no lookup, no shared infrastructure between issuer and verifier. The price is symmetric: the server kept no state, so there is no state to delete. A stolen JWT is valid until exp no matter what you do, unless you check every request against a server-side denylist — at which point you have rebuilt a session store and kept the JWT parsing on top. The honest stateless mitigation is short-lived access tokens (5–15 minutes) plus a refresh token that is checked against the database, with rotation and reuse detection: the revocation window shrinks to the access token’s TTL instead of disappearing.

// app/lib/session.ts
import { cookies } from 'next/headers';
import { randomBytes } from 'node:crypto';
import { kv } from '~/lib/kv';

export async function createSession(userId: string) {
  const token = randomBytes(32).toString('hex'); // 256 bits — unguessable, meaningless
  const maxAge = 60 * 60 * 24 * 7;
  await kv.set(`session:${token}`, { userId, createdAt: Date.now() }, { ex: maxAge });

  (await cookies()).set('__Host-session', token, {
    httpOnly: true,   // JS can never read it — XSS cannot exfiltrate the cookie
    secure: true,     // required by the __Host- prefix
    sameSite: 'lax',  // cross-site POSTs do not carry it
    path: '/',        // required by the __Host- prefix
    maxAge,
  });
}

export async function revokeSession(token: string) {
  await kv.del(`session:${token}`); // dead on the next request — this is the whole point
}
Pick the best fit

A fintech SaaS serves browser clients only. Security policy requires that any active session be terminable within 60 seconds of an admin command. The service runs on serverless functions (Vercel) with a Postgres database already in use. Which auth strategy fits?

When you look at a session cookie in the browser’s devtools, you are looking at a security contract written in four attributes. Miss one, and that contract has a hole an attacker can walk through.

Whichever token you choose, in a browser it should live in a cookie with the full attribute set, because each attribute closes a real attack. HttpOnly makes the cookie invisible to JavaScript — an XSS payload cannot exfiltrate it (be honest about the limit: XSS can still act as the user by firing same-origin requests; HttpOnly stops theft, not abuse). Secure keeps it off plaintext HTTP. SameSite=Lax — the default worth keeping — means cross-site POSTs do not carry the cookie at all, which is your first CSRF layer; only top-level GET navigations send it, which is why a GET handler that mutates state is a self-inflicted CSRF hole.

The underrated one is the __Host- prefix. A cookie named __Host-session is only accepted by the browser if it is Secure, has path=/, and has no Domain attribute — meaning it is host-locked. Compare that to the legacy pattern Domain=.example.com: now any subdomain can read and, worse, set that cookie. One compromised status.example.com — an abandoned marketing microsite, a vulnerable third-party tool on a subdomain — can plant a session cookie on the parent domain (cookie tossing) and walk a victim into an attacker-controlled session. The __Host- prefix turns that whole class off with a naming convention the browser enforces.

Quiz

Auth uses 24-hour JWTs verified by signature only. At 14:00 security clicks 'revoke all sessions' for a compromised user, which deletes the user's refresh tokens. What is the attacker's actual remaining access window?

Where the store lives on serverless

Sessions need a store, and on serverless that store cannot be process memory. A module-level Map works flawlessly in next dev — one long-lived process — and then produces ghost bugs in production: each function instance has its own memory, instances run in parallel and are recycled without notice. Login writes the session into instance A; the next request lands on instance B and looks like a logged-out user; deploys wipe everything. The store must be external, and the realistic options have honest numbers attached. A region-local Redis/KV (Upstash, Vercel KV) answers in roughly 1–2 ms p50 — the cheapest authenticated-request tax you can buy, though p99 over HTTP-based KV can stretch to 10+ ms. A Postgres sessions table costs 5–15 ms and adds connection-pool pressure from functions, but gives you transactions and one fewer system. Encrypted-cookie sessions (the iron-session pattern) need no store at all — and have exactly the JWT revocation problem with a different codec, since the server again keeps nothing it can delete.

Against that, a JWT HMAC verification is tens of microseconds of CPU and zero IO. That gap — ~1 ms versus ~0.05 ms — is real but rarely decisive for a dashboard; it becomes decisive when verification happens on a CDN edge for every request, or across services that cannot share a store. The hybrid most teams converge on: short-lived JWT access token (5–15 min) verified locally, refresh token in the store. The revocation window equals the access TTL — write that sentence into your security docs, because it is the number the auditor will ask about. If the requirement is “terminate access within 60 seconds,” your access token lives ≤60 seconds and you are hitting the store every minute anyway — at which point plain sessions are the simpler system with the same cost.

Why this works

Why do the Next.js auth docs and OWASP both steer browser auth toward opaque server-side sessions when JWTs are the louder default in tutorials? Because JWTs were designed for a different shape of problem: short-lived, service-to-service delegation, where the issuer and the verifier are different systems that cannot share a session store, and where tokens expire in minutes so revocation barely matters. A browser session is the opposite shape: one issuer, one verifier (your app), long lifetimes, and a hard operational requirement to kill access on demand — stolen laptops, fired employees, compromised accounts. Statelessness solves a distribution problem browser apps mostly do not have, and creates a revocation problem they very much do.

Quiz

A team keeps sessions in a module-level Map in their Next.js app deployed to serverless functions. Everything works in development. What happens in production?

Recall before you leave
  1. 01
    Why can't a plain JWT be revoked, and what is the honest mitigation with its exact tradeoff?
  2. 02
    What does each session-cookie attribute defend against, and what does the __Host- prefix add?
Recap

A stateful session puts an opaque random token (256 bits from crypto.randomBytes) in the cookie and the truth in a server-side store: every authenticated request pays one lookup, and in exchange revocation is deleting a row — effective immediately — logout-everywhere is deleting all of a user’s rows, and rotation on login and privilege escalation closes session fixation. A JWT carries signed claims with the request and verifies locally in tens of microseconds with zero IO, which is exactly why it cannot be recalled: the server kept no state, so a stolen token is valid until exp, and a denylist check on every request just rebuilds the session store. The realistic stateless compromise is a short-lived access token plus a store-checked refresh token with rotation and reuse detection, and its security property is one sentence: the revocation window equals the access token TTL. Browser-side, the cookie attributes are half the model — HttpOnly against exfiltration (not against same-origin abuse), Secure, SameSite=Lax as the first CSRF layer with the corollary that GET handlers must never mutate, and the __Host- prefix to host-lock the cookie, closing subdomain cookie tossing that Domain=.example.com invites. On serverless the store cannot be process memory — module-level Maps pass dev and smoke tests, then cause probabilistic logouts across instances — so it is region-local KV at ~1-2 ms p50, Postgres at 5-15 ms with pooling pressure, or encrypted-cookie sessions which quietly re-import the JWT revocation problem. Now when you hear a teammate say “let’s use JWTs so we don’t need a store,” you know the follow-up question: what is your revocation window, and is your policy fine with it?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.