Stateful Sessions vs Stateless Tokens
The single decision that shapes your whole architecture: does the server remember the login, or does the credential carry it?
Strip away the acronyms and there are only two designs.
Stateful: the server remembers
On login the server stores a record — session 8f2b… belongs to user 42 — and hands the client a meaningless id. Every request the client sends the id back; the server looks it up.
- Revocation is instant. Delete the row and the session is dead this millisecond.
- The credential leaks nothing. It is a random string with no information in it.
- Every request costs a lookup — normally Redis, so microseconds, but it is shared state that every API instance needs.
Stateless: the credential remembers
On login the server hands out a signed token containing the claims (sub: 42, exp: …). Nothing is stored. Every request the server verifies the signature and reads the claims straight out of the token.
- No shared store — any instance in any region can verify with just a key.
- Revocation is the hard part. A signed token stays valid until it expires; that is what "stateless" means.
- The contents are readable by anyone holding the token. Signed ≠ encrypted.
How they are usually mixed
Most production systems are hybrids. A short-lived stateless access token (5–15 minutes) keeps request handling cheap, while a stateful refresh token stored server-side gives you a revocation switch. Ban the user, delete the refresh row, and they are locked out within one access-token lifetime.
Example
// Stateful — the id means nothing on its own
const sid = crypto.randomBytes(32).toString('base64url');
await redis.set(`sess:${sid}`, JSON.stringify({ userId: 42 }), 'EX', 1800);
// Log out = one DEL. Instantly effective everywhere.
// Stateless — the token carries the facts and its own deadline
const token = jwt.sign({ sub: 42, scope: 'orders:read' },
PRIVATE_KEY, { algorithm: 'RS256', expiresIn: '10m' });
// Log out = ...nothing you can do to this string. It works until exp.When to use it
- A single-region monolith serving its own browser frontend keeps plain server sessions, because instant logout matters more than avoiding a Redis lookup.
- A fleet of stateless microservices across three regions verifies short-lived JWTs locally so no request has to cross a region for a session lookup.
- A banking app pairs 5-minute access tokens with a server-stored refresh token so 'log out all devices' actually works.
More examples
Choosing between them
The cross-site case is the one that pushes most teams to tokens: cookies across unrelated registrable domains are increasingly blocked by browsers.
# Pick STATEFUL SESSIONS when...
- the API only serves your own first-party browser app
- frontend and API share a site (abc.com + api.abc.com)
- instant logout / ban is a hard requirement
- you already run Redis or can afford one lookup per request
# Pick STATELESS TOKENS when...
- mobile or native clients call the API
- third parties call it via OAuth
- services call each other machine-to-machine
- the frontend is on a genuinely different site (abc.com → dfg.com)
- instances are spread across regions with no shared store
# Pick BOTH (the usual answer) when...
- you want cheap verification AND a working revocation switch
→ short-lived access token + server-stored refresh tokenThe hybrid, in outline
The access-token lifetime is exactly your worst-case revocation delay — that is the number to argue about, and 5 to 15 minutes is the usual landing zone.
// Login: stateless access token + stateful refresh token
const accessToken = signJwt({ sub: user.id }, { expiresIn: '10m' });
const refreshToken = crypto.randomBytes(32).toString('base64url');
await db.refreshTokens.insert({
hash: sha256(refreshToken), // store the hash, never the value
userId: user.id,
family: crypto.randomUUID(), // for reuse detection on rotation
expiresAt: addDays(new Date(), 30),
});
// Ban a user: their access token still works for <= 10 minutes,
// then every refresh attempt fails and they are out everywhere.
await db.refreshTokens.deleteMany({ userId });
Discussion