open atlas
↑ Back to track
Security Foundations SECF · 03 · 02

Sessions vs tokens

Server sessions are stateful and instantly revocable; bearer tokens are stateless and fast but live until they expire. The real tradeoff is revocation latency vs lookup cost — and where each one breaks in production.

SECF Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A user clicks “log out everywhere” after losing their laptop. With server sessions, you run one DELETE FROM sessions WHERE user_id = ? and every stolen cookie is dead on the next request. With the JWT access tokens your team shipped last quarter, you click the button, get a green toast — and the attacker’s token keeps working for another fourteen minutes, because nothing on the server actually checks whether that token is still wanted. The signature is valid, the expiry hasn’t passed, so your API waves it through. That gap between “I revoked it” and “it stops working” is the whole difference between the two designs, and it is the thing that gets quietly skipped in the demo and discovered during the incident.

By the end of this lesson you’ll know exactly what trades away when you pick stateless bearer tokens over server sessions, why revocation is the hinge, and where each design breaks in production.

Two ways to remember a logged-in user

After a user proves who they are, the server has to remember that fact across every later request, because HTTP itself is stateless — each request arrives with no memory of the last. There are two dominant ways to carry that “you are logged in” fact, and they sit at opposite ends of one axis: where the truth lives.

A server session keeps the truth on the server. At login the server creates a record — session_id → {user_id, created_at, roles, …} — in a store (Postgres, Redis, a signed encrypted cookie store) and hands the client an opaque random session_id in a cookie. The cookie is just a lookup key; it means nothing on its own. Every request, the server takes that id, looks up the record, and that record is the source of truth. This is stateful: the server holds session state and consults it on every call.

A bearer token (a JWT is the common shape) keeps the truth in the token. At login the server builds a small JSON payload — {sub: user_id, roles, exp} — and signs it with a secret. The client stores the token and sends it on every request, usually as Authorization: Bearer <token>. The server verifies the signature with the same secret and believes the claims inside — no database lookup, no shared store. This is stateless: any server holding the verification key can validate the token alone. “Bearer” is literal: possession is authorization. Whoever holds the token is treated as the user, exactly like cash.

The real tradeoff: revocation latency vs lookup cost

People sell tokens as “scalable, no database hit per request” and sessions as “old and slow.” That framing hides the actual decision. The honest tradeoff is a swap of one cost for another.

Sessions buy you instant revocation by paying a per-request lookup. Because the server reads the session record on every call, you can change or kill it at any moment: delete the row to force logout, flip a roles field to demote a user mid-session, or invalidate every session for an account after a password reset. The cost is that lookup — a Redis GET is sub-millisecond, a Postgres read more — and the store is now a dependency on the hot path of every authenticated request.

Tokens buy you a stateless hot path by paying with revocation you don’t have. There is no per-request store call, so any node with the key validates independently — that genuinely helps a fleet of stateless services and cross-domain APIs. But the same property that removes the lookup removes the place to say “stop.” A signed token is valid until its exp passes; the server has no record to delete. So the moment you need real revocation — logout-everywhere, a stolen token, an emergency demotion — you have to bolt state back on (a denylist, a short-lived-access-plus-refresh scheme), at which point you are paying a lookup again and the “stateless” advantage you bought it for is partly gone.

That is the senior framing: you are not choosing fast vs slow, you are choosing where revocation latency lives. Sessions put it at zero and charge a lookup. Pure tokens push it out to the token’s lifetime and charge you an incident when that window is wrong.

Common mistake

The most common production mistake is shipping long-lived JWTs — an exp of hours or days — as if they were sessions, then discovering at incident time that “log out” and “ban this user” don’t actually work. The mitigation that makes tokens revocable is the short-lived access token (5–15 min) paired with a refresh token stored server-side. The access token stays stateless and dies fast; the refresh token is the stateful, revocable part — revoking it stops new access tokens, and the longest an already-issued access token survives is its short expiry. You haven’t escaped state; you’ve shrunk the window it has to cover to a few minutes.

Where each one breaks

Each design has a signature failure mode, and knowing them is how you pick.

Sessions break on session fixation and on the store. Session fixation: if the server accepts a session id supplied by the attacker (e.g. from a crafted link) and then upgrades that same id to authenticated on login, the attacker — who already knows the id — is now logged in as the victim. The fix is non-negotiable and one line of discipline: regenerate the session id on every privilege change, especially at login, so the pre-auth id can never carry into an authenticated session. Sessions also make the store a hard dependency: if Redis is down, nobody is authenticated, so it needs the same availability as the app itself.

Tokens break on revocation and on storage. Revocation: covered above — a leaked token is good until exp, and “logout” is a lie unless you’ve added a denylist or short expiry. Storage: a bearer token is cash, so where the browser keeps it decides the blast radius. In localStorage it is readable by any JavaScript on the page, so a single XSS exfiltrates it; in an HttpOnly cookie it is invisible to JS (XSS can’t read it) but now rides along automatically and you’ve re-introduced CSRF exposure that you must counter with SameSite and anti-CSRF tokens. There is no storage location that is free of tradeoffs — there is only the one whose tradeoff you’ve consciously handled.

DimensionServer sessionBearer token (JWT)
Source of truthServer-side store (cookie = opaque key)The signed token itself (claims)
State on hot pathLookup every request (stateful)Signature verify, no lookup (stateless)
RevocationInstant — delete the recordNone until exp, unless you add a denylist
Scale shapeNeeds a shared/available storeAny node with the key validates alone
Signature failure modeSession fixation; store outage = no authLeaked token valid till exp; XSS/CSRF storage

How a senior actually decides

Default to server sessions for first-party web apps: same origin, a browser that already does cookies well, and a hard requirement that “log out” and “ban” work now. The per-request lookup against Redis is cheap and buys you the revocation you’ll need the day something leaks. Reach for tokens when the shape demands statelessness — service-to-service calls, mobile and third-party API clients, or a fleet where no shared session store fits — and when you do, make them short-lived access tokens with server-side refresh, so the stateless part is bounded to minutes and the revocable part lives in the refresh token. The wrong answer is the middle: a long-lived JWT used as a de-facto session, which gives you a token’s revocation problem and a session’s blast radius at the same time.

Pick the best fit

A first-party web app must support 'log out everywhere' and instant ban-on-abuse. Auth runs against a Redis you already operate. Pick the design.

Quiz

A teammate says 'JWTs are more secure than sessions because nothing is stored on the server.' What's the precise correction?

Quiz

Why must a session-based app regenerate the session id at login?

Order the steps

Order the steps that make a stateless access token actually revocable in production, from issue to enforced logout:

  1. 1 Issue a short-lived access token (5–15 min) plus a refresh token stored server-side
  2. 2 Client calls APIs with the access token — stateless, no lookup, until it expires
  3. 3 On logout/ban, revoke the refresh token in the server-side store
  4. 4 The expired access token can't be refreshed, so access ends within the short expiry window
Recall before you leave
  1. 01
    Explain the real tradeoff between server sessions and bearer tokens — not 'fast vs slow' but what is actually being swapped.
  2. 02
    Where does each design break in production, and how do you mitigate each failure mode?
Recap

HTTP forgets you between requests, so the server needs a way to carry “this user is logged in” — and there are two, split by where the truth lives. Server sessions keep it server-side: the cookie is an opaque key, the store record is the truth, and because the server reads it every request you get instant revocation — delete the record and the next call is denied. Bearer tokens (JWTs) keep the truth in a signed payload the client carries; the server verifies and believes it with no lookup, which is stateless and great for fleets and third parties, but means a signed token is valid until its exp and there is nothing to delete. The real tradeoff is therefore revocation latency vs lookup cost, not speed: sessions put revocation at zero and pay a store dependency; pure tokens push it out to the token lifetime. Each breaks differently — sessions on fixation (regenerate the id on login) and store outage; tokens on revocation (use short-lived access plus server-side refresh) and on where the browser stores them (localStorage vs HttpOnly, XSS vs CSRF). The senior default is sessions for first-party web apps and short-lived access-plus-refresh tokens when the shape genuinely demands statelessness — and never a long-lived JWT pretending to be a session.

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
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.