Session vs. Token

HTTP forgets you the moment the request ends. Every click is a stranger. The server remembers you through one of two tricks: an opaque cookie that points back to server memory, or a signed token that carries the truth with it. Try both. See what each one costs.

Scenario

Browser you
Server api.example.com
Session store redis
Ready
Cookie Session · opaque ID 0 / 0 steps
Pick a scenario and click Play flow. Each step is a real HTTP request or a real server-side lookup. Click any step card to see the exact bytes on the wire.

HTTP exchange

Side by side

Cookie session

  • Revocation: instant. Delete one row from Redis, the session is dead.
  • Scale: needs shared state. Every server reads the same session store.
  • Payload: a 32 byte opaque ID. Contains no user info.
  • Transport: always a Cookie header. Browser sends it automatically.
  • Breach: the store is a prize. One Redis dump, all sessions.

JWT

  • Revocation: cannot, without a blacklist. That blacklist is just a session store in disguise.
  • Scale: any server can verify with the public key. No shared state.
  • Payload: a signed JSON object. Claims live inside: sub, exp, roles.
  • Transport: Authorization: Bearer header, or a cookie. App chooses.
  • Breach: if the signing key leaks, an attacker can mint their own tokens.

What just happened

Nerd tip

HttpOnly, Secure, SameSite. The cookie trifecta. HttpOnly hides the cookie from JavaScript so an XSS bug cannot read your session ID. Secure refuses to send the cookie over plain HTTP, so a coffee-shop AP cannot intercept it. SameSite=Lax (or Strict) blocks the cookie from auto-riding on cross-site requests, which kills most CSRF. Chrome started defaulting SameSite=Lax in Feb 2020 and it silently fixed a decade of sloppy apps. If you see a session cookie missing any of these three, the app has a bug.

Nerd tip

You cannot revoke a pure JWT before it expires. That is the whole point and the whole problem. The token is self-contained proof. Any server with the public key will accept it until exp. Real systems work around this three ways: (1) keep access tokens under 15 minutes and rely on refresh-token rotation, (2) keep a blacklist of revoked jti values (which is a session store, so you lost the stateless benefit), (3) rotate the signing key and invalidate every token at once (nuclear option). Anyone who tells you JWTs give you free instant logout has not shipped one.

Nerd tip

Refresh-token reuse detection is the canary. Rotate the refresh token on every redeem. Keep them in families: every new refresh inherits the family ID of the one it replaced. If an old, already-redeemed refresh token is ever presented again, that is an attacker holding a stolen copy. The correct response is to revoke the entire family, forcing the real user to log in again. Auth0, Cognito, and Supabase all implement this pattern. The alg:none JWT attack of the mid-2010s and the kid path-traversal attacks that followed are why you also pin algorithms: ["RS256"] explicitly on every verify call.

Nerd tip

Access tokens do not belong in localStorage. Any script on the page, including one injected by a compromised npm dependency, can read localStorage. The modern pattern is: access token lives in a JavaScript variable in memory (gone on page reload, that is fine), refresh token lives in an HttpOnly; Secure; SameSite=Strict cookie scoped to the refresh endpoint only. On reload, the app POSTs to /refresh, the browser auto-sends the refresh cookie, a new access token comes back. XSS cannot reach either one directly.