Sessions vs tokens: where does the user's identity actually live?
Authentication has two dominant models, and the whole comparison comes down to one question: where does the source of truth about the logged-in user live? With a session, it lives on the server — the server stores the session record and hands the browser only an opaque id (a key that means nothing on its own). With a token (typically a JWT), it lives in the token itself — a signed claim the browser holds, that the server can verify without looking anything up. That single difference — stateful server vs stateless self-contained claim — drives everything else: how you revoke access, how you scale, and where the thing must be stored to stay safe.
Sessions: stateful, and easy to revoke
The server creates a record, stores it (in memory, Redis, a DB), and sends the
browser an opaque id in an HttpOnly cookie. Every request, the server looks the id
up to learn who you are:
// login: create server-side state, hand the browser only an opaque id
const sessionId = crypto.randomUUID();
await store.set(sessionId, { userId: user.id, createdAt: Date.now() });
res.cookie("sid", sessionId, { httpOnly: true, secure: true, sameSite: "lax" });
Because the truth is server-side, revocation is trivial: delete the record and the next request fails instantly. The cost is that the server must store and look up state on every request, which is the thing you have to scale.
Tokens: stateless, and self-verifying
The server signs a token containing the claims (sub, exp, roles) and hands it
over. It stores nothing; on each request it verifies the signature and reads the
claims directly — no lookup:
// login: sign a self-contained claim; the server keeps no record of it
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { expiresIn: "15m" });
// each request: verify and trust, with no database round-trip
const claims = jwt.verify(req.headers.authorization.slice(7), SECRET);
This scales beautifully — any server with the key can verify, no shared session
store — but the flip side is the hard part: you cannot easily revoke a token
before it expires, because nothing was stored to delete. A stolen token is valid
until exp.
The trade-offs, and where each fits
The revocation/scaling tension defines the choice. Sessions give instant logout,
easy “log out everywhere,” and server-side control, at the cost of stateful
lookups — ideal for classic web apps and anything needing strict control. Tokens
give stateless horizontal scaling and clean service-to-service auth, at the cost of
weak revocation — which teams patch with short token lifetimes plus a longer
refresh token, so a compromised access token dies in minutes. Storage matters
either way: keep the credential in an HttpOnly cookie so JavaScript (and thus XSS)
cannot read it — putting a JWT in localStorage is the common mistake that turns
any script injection into account theft. And decoding is not verifying: a JWT
payload is just base64, so a claim is only trustworthy after the signature checks
out. The session-and-tokens exercise builds both flows so the revocation-vs-scaling
trade stops being abstract.