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.
Stateful session lookup versus stateless token verificationStateful sessionsid=8f2b…APIsession store8f2b… → user 42Stateless tokenJWTsub:42 exp:…APIverify signatureno lookup…and no instant revoke
The trade is always the same: a lookup you control, or a signature you cannot take back.

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

Example · javascript
// 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.

Example · bash
# 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 token

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

Example · javascript
// 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

  • Be the first to comment on this lesson.