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
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
Cookieheader. 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: Bearerheader, 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.