Skip to the content.

Sessions vs tokens: where does the user's identity actually live?

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.

A session stores state on the server; a token carries a signed claim in the client Session: browser holds an opaque id, server holds the record and looks it up each request. Token: browser holds a signed JWT, server verifies the signature with no lookup. session browser: id sends id server looks up session store token browser: signed JWT verify signature — no lookup
Session: the client holds a meaningless id and the server holds the truth. Token: the client holds the truth as a signed claim the server only needs to verify.

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.