practice · level 5

Session Management

Tokens, JWTs, refresh, revocation — and why 'JWTs can't be revoked' is a half-truth.

200 XP

Session Management

After a successful login, the server hands the browser a token. Every subsequent request carries that token; the server uses it to know "yes, this is alice". Pick the wrong shape for that token — or get the lifecycle wrong — and you ship one of the most common production security bugs of the 2020s.

The two shapes

There are exactly two useful designs:

Opaque, server-stored

The token is a random 32-byte string. The server stores {token → userId, expiry, ...} in Redis (or your DB). On each request, the server looks up the token; if it exists and isn't expired, the user is authenticated.

  • Pros: revocation is trivial (DEL sess:<token>), the cookie is meaningless to the client, sliding-window expiry is one Redis call.
  • Cons: every request hits the session store. (In practice that's a 1ms Redis call; not a problem at any sensible scale.)

This is what most production web apps actually use. It is the right default.

Self-contained / signed (JWT, Macaroons)

The token contains the user-id, the expiry, and any claims, signed with a server key. The server verifies the signature without a database hit.

  • Pros: stateless verification — every pod can validate without a shared store. Useful in cross-service / cross-domain settings.
  • Cons: revocation is non-trivial (you have to invalidate before the exp somehow), and the bigger token round-trips on every request.

Use only when you genuinely need the stateless property — usually that means you're handing the token to a different service that doesn't share your session store.

"JWTs can't be revoked" — the half-truth

The phrase is everywhere and it's almost true. By default, a JWT validates on signature + exp alone — once issued, it's good until it expires. If a user gets compromised, "log them out" doesn't mean anything to the validating service.

The fix is a server-side denylist:

On revoke(jti):
  redis.setex(`jwt:revoked:${jti}`, ttl=remainingExpiry, "1")

On verify(token):
  decoded = jwt.verify(token, key)
  if redis.exists(`jwt:revoked:${decoded.jti}`): reject

You've now reintroduced the database lookup the JWT was supposed to eliminate. Fine — keep the JWT for the authenticated subject and use the lookup only for the revocation check (which can be cached aggressively).

If your design needs both stateless validation AND useful revocation, use short-lived access tokens (15 min) plus refresh tokens: the access JWT expires fast enough that the revocation window is small; the refresh is opaque, server-checked, and revocable.

Refresh tokens

The OAuth 2.0 pattern that everyone copies:

Token Type Lifetime Sent on
Access JWT or opaque 5–15 min Every API call (Authorization: Bearer …)
Refresh Opaque, server-stored 7–30 days Only to /auth/refresh

When the access token expires, the client posts the refresh token to /auth/refresh and gets a new pair. If the refresh is revoked, the user has to log in again.

Two non-negotiable details:

  1. Rotate refresh tokens on use. Each use returns a new refresh + invalidates the old one. If an old refresh ever shows up again, the chain has been replayed — invalidate the whole family and force re-auth. (This is "refresh token rotation with reuse detection.")
  2. Bind the refresh token to its client. Same device, same browser. If you can, bind to a public-key proof (DPoP, RFC 9449).

Sliding window vs absolute expiry

  • Absolute: token expires N minutes after it was issued. Period. No matter how active the user is, they get logged out at exactly N.
  • Sliding: every request resets the timer. As long as the user is active, the session lives forever (in practice you cap with an absolute upper bound — say 30 days).

Most consumer apps want a hybrid: 30-minute sliding window with a 12-hour absolute cap (for sensitive actions like banking) or 30-day absolute cap (for low-risk webmail-style sessions).

// Redis sliding window — one call per request:
await redis.expire(`sess:${sid}`, 30 * 60);
// Plus an absolute cap stored in the value:
const sess = JSON.parse(await redis.get(`sess:${sid}`));
if (Date.now() > sess.absExp) await redis.del(`sess:${sid}`);

Sticky sessions vs distributed

If your session lives in pod-local memory:

  • Sticky session (LB-level affinity) routes the user back to the same pod. Works until the pod dies — then the session vanishes mid-flow.
  • Distributed session (Redis or DB-backed) decouples the session from the pod. Any pod handles any user. The pod can die; the session survives.

For 2024+ infrastructure, the answer is almost always distributed. Sticky sessions are a 2010s pattern that doesn't survive auto-scaling, rolling deploys, or spot-instance preemption.

If you must have sticky for some other reason (long-running WebSocket state, etc), pair it with a distributed store as a safety net — the session lives in Redis, the in-memory copy is just a cache.

Cookie hygiene checklist

For first-party browser sessions, the cookie carrying the session token must have:

Set-Cookie: sid=<random>;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=2592000
  • HttpOnly — JS cannot read it. Defends against XSS-stealing the token.
  • Secure — sent only over TLS.
  • SameSite=Lax — blocks CSRF on cross-site POSTs and most cross-site GETs that aren't top-level navigation.
  • Path=/ — limit scope as much as possible.
  • A short-ish Max-Age — even with sliding window, a cap keeps things bounded.

Never put the session token in localStorage or in a non-HttpOnly cookie. XSS becomes account-takeover the moment you do.

What goes wrong in production

  • No revocation path on JWTs. Compromise → "we'll wait for the tokens to expire." That's how 24-hour windows of attacker control happen.
  • Long-lived access tokens. 7-day access tokens are 7-day attacker windows. Cap at 15 minutes; let refresh handle continuity.
  • Storing tokens in localStorage. Any XSS = account takeover. Cookies with HttpOnly survive XSS that doesn't include service-worker tricks.
  • Forgetting to log out the OTHER device. "Sign out everywhere" must invalidate the refresh token AND the session-store entry on the server, not just the local cookie.
  • Sticky sessions plus auto-scaling. Sessions evaporate on rolling deploys. Migrate to a distributed store.
  • No idle timeout on admin sessions. A laptop left open at lunch is a security incident.

What "right" looks like

For a typical first-party web app with a backend you control:

  • Opaque server-stored session token (32 random bytes), signed cookie (HttpOnly; Secure; SameSite=Lax).
  • Redis-backed store, sliding 30-minute window, 30-day absolute cap.
  • Explicit "log out" path that deletes the server-side entry.
  • "Sessions" page in user settings showing all active sessions with device/IP/location, with a "revoke" button.
  • Audit log of every login, logout, MFA event, and session revocation.

For a multi-service / mobile-app scenario:

  • Short-lived access JWT (15 min) carrying user-id and minimal claims.
  • Opaque refresh token (32 bytes) stored server-side, rotated on use, with reuse detection.
  • Server-side jti-denylist for emergency JWT revocation.
  • DPoP or mTLS binding the refresh to the client where possible.

Get this right once, copy the shape into every service, and stop reinventing it. The cost of a session-management bug is "all your users are compromised at once" — there is no shape of mitigation that recovers from that.

Tools in the wild

5 tools
  • Redisfree tier

    The de-facto session store: cheap reads, TTL out of the box, sliding-window expiry trivial.

    service
  • iron-sessionfree tier

    Stateless sealed-cookie sessions for Node — encrypted, signed, server-readable.

    library
  • Battle-tested session middleware for Express — works with any store.

    library
  • jsonwebtokenfree tier

    JWT encode/decode for Node. Pair with a denylist for revocation.

    library
  • Hosted auth with session + refresh-token flows out of the box.

    service