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.

Left: the browser sends Cookie sid=9f2c to server A, which asks the session store whose session 9f2c is and gets alice back. Right: the browser sends Bearer token with sub alice and exp 10:15 to server A, which checks the signature with the public key and the exp locally, with no store
A session id means nothing without a lookup in the store; a JWT carries its own data and is checked with a key.

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.

A JWT split into three parts. Header: alg RS256, typ JWT. Payload: sub alice, role user, iat 10:00, exp 10:15, jti j1. Signature: RS256 over header dot payload with the private key. The header and payload are only encoded, anyone can read them; changing either breaks the signature
Header and payload are only encoded, not encrypted: anyone can read them, but nobody without the key can change them.

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

fixhowcost
short expaccess tokens live 5–15 minutesneeds refresh tokens; revocation is late by up to exp
denyliststore revoked jti until their expa lookup on every request again (Demo 3)
token versiona per-user counter in the token, compared with the databasealso 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).

Time flows to the right. Login issues rt1 in family f1. The attacker copies rt1. alice refreshes: rt1 becomes used and rt2 is issued. The attacker sends rt1: it is already used, so reuse is detected and family f1 is revoked. alice's rt2 is revoked too and she must log in again
A used refresh token coming back means two parties hold it; revoking the whole family locks out the thief at the cost of one extra login.

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

whereXSS can read it?sent automatically?main risk
cookie with HttpOnlynoyes, to its siteCSRF, limited by SameSite
localStorageyesno, the app adds a headerXSS steals it and uses it elsewhere
JavaScript memoryonly while the page runsnolost 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 cookieJWT
check per requeststore lookupsignature + exp, local
logout / banimmediateat exp, or a denylist lookup
many servers / servicesshared store or sticky sessionsonly the public key
size on every request~40 byteshundreds of bytes
good fitone web app with its own backendAPIs 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.