The idea: two ways to remember who is logged in
HTTP forgets everything between requests, so after login the browser must send something with every request that proves who it is. There are two common designs. A session gives the browser a random id in a cookie and keeps the real data on the server, in a session store. A JWT (JSON Web Token) gives the browser the data itself, signed, so any server can check it without asking anyone.
The canvas runs the same app twice. Every action happens on both sides at once: left with a session cookie, right with a JWT and a refresh token. Two servers, A and B, take turns behind a load balancer. An attacker box holds whatever has been stolen. Pick an action and press Do It, or run a demo.
Session cookies
At login the server makes a random id (128 bits or more), stores id → user in the session store with an expiry, and sends Set-Cookie: sid=…. The browser sends the cookie back on every request by itself. The server looks the id up to learn who it is (Demo 1: log in and two requests: two requests, two lookups). The id carries no information, so a stolen id works only as long as the session exists, and deleting the session ends it everywhere.
With several servers, every server must reach the same store (Redis, a database), or the load balancer must send each user back to the same server (sticky sessions), which breaks when that server dies.
What is in a JWT
A JWT is three base64url parts joined by dots: header.payload.signature.
The server checks the signature with the key, then the claims: exp (expiry), and often iss and aud. No store, no network call: any server with the public key can do it. That is the main reason to use a JWT: stateless checks across many servers and services. On the canvas the JWT side's lookup counter stays at 0 while the session side counts one per request.
Logout and revocation
The price of not asking anyone is that nobody can say "stop". After logout a session is deleted, so a stolen copy fails at once. A JWT that has been issued stays valid until its exp, for anyone who holds it (Demo 2: log out, a stolen copy still works: the attacker's copy still gets 200 until 10:15). The same happens when an admin bans a user (Demo 7).
| fix | how | cost |
|---|---|---|
short exp | access tokens live 5–15 minutes | needs refresh tokens; revocation is late by up to exp |
| denylist | store revoked jti until their exp | a lookup on every request again (Demo 3) |
| token version | a per-user counter in the token, compared with the database | also a lookup |
Refresh tokens and rotation
A short-lived access token needs a way to get a new one without logging in again: the refresh token. It is long-lived, sent only to the refresh endpoint, and stored in the database, so it can be revoked, and the refresh is where a ban takes effect. With rotation, each refresh returns a new refresh token and marks the old one used (Demo 4).
If a used token ever comes back, two parties hold the same token. The server cannot tell which is the thief, so it revokes the whole family (Demo 5: stolen refresh token, reuse detected).
Where the credential lives: HttpOnly, Secure, SameSite
| where | XSS can read it? | sent automatically? | main risk |
|---|---|---|---|
cookie with HttpOnly | no | yes, to its site | CSRF, limited by SameSite |
localStorage | yes | no, the app adds a header | XSS steals it and uses it elsewhere |
| JavaScript memory | only while the page runs | no | lost on reload; needs a refresh cookie |
Demo 6: XSS shows the difference: the injected script cannot read the HttpOnly session cookie, but takes the access token from localStorage and uses it from its own machine. Secure keeps a cookie off plain HTTP. SameSite=Lax stops the browser sending it on cross-site POSTs, the main defense against CSRF. Malware on the device steals everything either way. The storage choice is independent of the format: a JWT can live in an HttpOnly cookie too. See Cookies for cookie attributes in detail.
Signature pitfalls
A forged session id is just a guess, and the store does not know it. A forged JWT is caught only by the signature check (Demo 8), so that check must be strict. Reject alg: none. Pin the algorithm instead of trusting the token's header: in the key confusion attack a server that expects RS256 is handed an HS256 token signed with the public key as the HMAC secret, and a library that follows the header accepts it. Keep the payload free of secrets, since anyone can decode it. See Digital Signatures for how RS256 signing works.
Which one to use
| session cookie | JWT | |
|---|---|---|
| check per request | store lookup | signature + exp, local |
| logout / ban | immediate | at exp, or a denylist lookup |
| many servers / services | shared store or sticky sessions | only the public key |
| size on every request | ~40 bytes | hundreds of bytes |
| good fit | one web app with its own backend | APIs across services, tokens from an identity provider |
For a single web app, a session cookie is simpler and easier to revoke. JWTs pay off when many services must check identity without a shared store, which is how OAuth and OpenID Connect hand out access tokens and ID tokens.
See also How an API Gateway Handles a Request: a gateway checks the JWT once, with cached JWKS keys, before any service sees the request.
See also HttpOnly Cookie vs localStorage: the same token in both places, against XSS theft, an XSS request from the page, and a cross-site form with each SameSite value.
What the page leaves out
Password hashing and login checks, CSRF tokens, session fixation (a new id must be issued at login), key rotation with kid and JWKS, encrypted tokens (JWE), clock skew between servers, absolute session lifetimes, and the timing of an automatic refresh in a real app.