open atlas
↑ Back to track
NestJS, zero to senior NEST · 05 · 04

Sessions vs JWT, and why logout is hard

Sessions store auth server-side and revoke by deleting a row; a JWT carries its own claims and cannot be revoked before exp. That is why logout is hard with JWT — clearing the cookie does nothing to a stolen token. Mitigate with short TTL plus a denylist or per-user tokenVersion.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The support ticket said: “a user is harassing people in chat — ban them, now.” The on-call engineer hit the ban endpoint, watched the row flip to banned: true, and replied “done.” Forty minutes later the abuse was still coming. The account was banned in the database, and still posting. Nobody had revoked anything, because there was nothing to revoke: the app authenticated every request from a stateless JWT, and the abuser’s token had been minted twenty minutes before the ban with a one-hour expiry. The ban flag was real; the token didn’t care. It kept verifying, kept passing the guard, kept letting writes through — until it expired on its own schedule, not ours. Logout, it turns out, is not a UI action. With the wrong auth model it is a thing you cannot do.

Two models: where the auth state lives

Before you can pick an auth model or debug a logout that “didn’t work,” you need to understand one thing: where does the server keep the truth about who you are? The answer to that question dictates everything else — what revocation costs, how many round-trips a request takes, and whether “ban this user now” is a one-liner or an architectural problem. Every auth scheme answers one question on each request — “is this caller who they claim to be?” — and the two dominant models answer it from opposite places.

Stateful server sessions keep the truth on the server. Login creates a session row in a store (Redis or a DB table); the client gets back only an opaque, meaningless session id in an httpOnly cookie. Every subsequent request carries that id, and the server looks the session up to learn who you are.

// Stateful: the cookie holds an opaque id; the server holds the truth.
// Login writes a row; every request reads it back.
await store.set(`sess:${sid}`, { userId, roles }, { ttlSec: 3600 });
res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });

// On each request, a guard resolves identity by LOOKING IT UP:
const session = await store.get(`sess:${req.cookies.sid}`); // 1 store read
if (!session) throw new UnauthorizedException();             // revoked → instantly out

Stateless JWT keeps the truth in the token. The token is the claims (sub, roles, exp), signed by the server. Verification is local: check the signature and exp, trust the payload. No store, no lookup.

// Stateless: the token carries the claims, signed. Verify is local — zero lookups.
const token = jwt.sign({ sub: userId, roles }, SECRET, { expiresIn: '15m' });

// On each request: verify signature + exp, then trust the payload. No I/O.
const claims = jwt.verify(req.headers.authorization?.slice(7), SECRET);

The difference is the whole lesson. Sessions cost one store read per request and force you to share that store across every app instance (sticky sessions, or a central Redis — shared state is the price). JWT costs zero lookups and scales horizontally with nothing shared — which is exactly why it cannot be revoked: there is no row to delete.

The logout problem: trivial with sessions, hard with JWT

“Log this user out everywhere” / “ban this account NOW” is the same operation, and it exposes the asymmetry brutally.

With sessions it is one line: delete the row. The next request’s lookup misses, the guard throws, and the user is out — on every device, instantly, globally. The server held the state, so the server can destroy it.

// Sessions: revocation IS deletion. Instant, global, done.
await store.del(`sess:${sid}`);        // one device
await store.del(`user-sessions:${userId}`); // or every device — next lookup misses

With JWT there is no such line. The token is self-validating: any holder of the bytes is authenticated until exp, full stop. You can clear the cookie — but that only affects a cooperating client. A token copied by a thief, or replayed by the abuser’s script, is untouched. There is no server-side handle to pull.

So you simulate revocation, and every option is a tradeoff:

// (1) SHORT access-token TTL — shrink the un-revocable window to minutes.
//     This is why refresh tokens exist (covered separately): exp does the revoking.
jwt.sign({ sub: userId }, SECRET, { expiresIn: '15m' }); // window ≤ 15 min

// (2) DENYLIST revoked jti until their exp — but this is a per-request lookup again,
//     reintroducing the shared state you adopted JWT to escape. A hybrid.
if (await denylist.has(claims.jti)) throw new UnauthorizedException(); // +1 read

// (3) PER-USER tokenVersion (a.k.a. minIssuedAt) — bump it on logout-all /
//     password-change; reject any token issued before. One indexed lookup,
//     invalidates ALL of a user's tokens at once. The pragmatic middle ground.
const u = await users.findById(claims.sub);               // 1 indexed read
if (claims.tokenVersion !== u.tokenVersion) throw new UnauthorizedException();

Option (1) caps the damage window but never closes it on demand. Option (2) gives precise per-token revocation but pays back the per-request read you went stateless to avoid. Option (3) is the one most teams land on: a single indexed column, bumped once, kills every token a user holds — the right tool for “ban now” and “log out all devices.”

Why this works

Why doesn’t “just delete the cookie on logout” actually log a JWT user out? Because a JWT is self-validating — it carries its own signature and the server trusts it without a lookup. Any party holding the raw bytes is authenticated until exp: the legitimate browser, sure, but also a thief who copied the token, or a script replaying it. Clearing the cookie only removes the copy in the cooperating browser; it cannot reach into a stolen or replayed copy, because the server keeps no record of issued tokens to invalidate. The cookie-clear is a client-side courtesy, not a revocation. True revocation needs server-side state the server can change — a denylist entry, or a bumped tokenVersion — so that the next verify can reject a token the signature would otherwise accept.

Where each model fits — and why real systems are hybrid

Reach for sessions when instant global revocation is a hard requirement and a shared store is acceptable: classic server-rendered web apps, banking, admin consoles — anywhere “kill this access right now” must be true the next millisecond. Reach for JWT when a central session store is the bottleneck: stateless microservices, mobile/API clients, and cross-service auth where every hop revalidating against one Redis would serialize your fan-out.

Most mature systems refuse the binary and go hybrid: a short-lived stateless access JWT (zero lookups on the hot path) paired with a stateful refresh token stored in the DB (revocable). You already hold server-side state at the refresh boundary, so revoking the refresh token stops new access tokens from being minted — and the short access TTL drains the ones already out. That is the architecture that gets you JWT’s scale and sessions’ revocability, with the un-revocable window bounded to the access TTL.

A few JWT footguns ride along regardless. Token size: a JWT in a header or cookie is far larger than an opaque session id and rides on every request — keep claims minimal, never stuff a user’s whole profile in. Clock skew: exp is compared against each server’s clock, so allow a small leeway (a few seconds) or a node with a fast clock will reject still-valid tokens. Cookie flags matter for both models: httpOnly keeps JS from reading the token, Secure keeps it off plaintext, SameSite blunts CSRF.

Pick the best fit

An admin must instantly ban a currently logged-in user across all of their devices. The app authenticates every request with a stateless access JWT (15-minute TTL). What is the best approach?

Quiz

Which auth model gives you instant, global revocation of a logged-in user, and why?

Quiz

Your API uses stateless access JWTs. You must force-log-out a user before their token's exp. Which approach works, and why does the naive one fail?

Recall before you leave
  1. 01
    Contrast stateful sessions and stateless JWT on where the auth state lives, the per-request cost, and revocation.
  2. 02
    Why does clearing the cookie fail to log out a JWT user, and what are the three real mitigations with their tradeoffs?
Recap

Every auth model answers “is this caller who they claim to be?” from one of two places, and that choice decides whether logout is possible. Stateful sessions keep the truth on the server: login writes a session row to Redis or a DB, the client holds only an opaque id in an httpOnly cookie, and every request costs one store read to resolve identity — plus a shared store across instances. The reward is that revocation is deletion: drop the row and the next lookup misses, so the user is out instantly and globally. Stateless JWT keeps the truth in the token — the signed claims themselves — so verification is local with zero lookups and nothing shared, which scales horizontally but means there is no row to delete: a JWT is valid until its exp no matter what, and clearing the cookie only removes a cooperating client’s copy, never a stolen or replayed one. That is why the banned user kept posting. To force-logout a JWT you reintroduce server state with a tradeoff: a short access TTL (~5–15 min) bounds the window, a denylist of revoked jti gives per-token precision at the cost of a per-request read, and a per-user tokenVersion bumped on logout-all kills every token a user holds with one indexed lookup. Sessions fit server-rendered apps, banking, and admin where instant revocation is non-negotiable; JWT fits stateless microservices, mobile/API, and cross-service auth where a central store is a bottleneck; and most mature systems are hybrid — a stateless access JWT paired with a stateful, revocable refresh token in the DB, revoking at the refresh boundary while the short access TTL drains the rest. Keep claims minimal (size rides every request), allow small clock-skew leeway against exp, and set httpOnly + Secure + SameSite on both models. Now when someone asks “why is the banned user still posting?”, you will immediately ask: what is the access token TTL, is there a tokenVersion column, and who revoked the refresh token? That is the right mental model — not “did we clear the cookie?”

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 5 done

Something unclear?

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

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.