Skip to content

8.4.2 — Sessions, JWTs and the Revocation Problem

An employee is dismissed at 14:00. Their account is disabled at 14:01. At 14:20 they download the customer list.

The system used JWTs with a one-hour expiry, and nothing checks the database on each request — that was the point of choosing them. The token issued at 13:45 remains cryptographically valid until 14:45, and the server has no way to say otherwise.

That is the trade at the centre of this chapter, and it is not a bug in JWTs. A stateless token is valid because of what it says, not because of what the server currently believes — which is exactly what makes it fast and exactly what makes revocation hard.

1. The problem both approaches solve

HTTP is stateless (Chapter 5.6.1): each request arrives with no memory of the last. After a successful login, every subsequent request must carry proof of who you are, and there are only two shapes for that proof.

A reference — an opaque identifier the server looks up. This is a session.

A claim — a self-contained signed statement the server verifies. This is a token.

session (reference)Cookie: sid=8f14e45fceea167a…server looks it up in Redisone network hop per requestdelete the row = instantly revokedJWT (claim)Bearer eyJhbGciOi… (sub, exp, roles)server verifies the signature locallyno lookup — nothing to deletevalid until it expires
The same login, two mechanisms. Everything either one is good or bad at follows from whether the server has to look something up.

2. Server-side sessions

On login the server generates a random session id, stores the session server-side, and sets it as a cookie. Every request carries the cookie; the server loads the session.

The id must be at least 128 bits of CSPRNG output and must contain nothing — not a user id, not a counter. It is a lookup key and nothing else, which is what makes it safe to hand out.

Where the session lives decides how the system scales. In process memory it is fastest and breaks the moment you run two instances — a user gets logged out randomly depending on which server answers, which is the symptom described in Chapter 9.9.4. Sticky sessions patch it and reintroduce the problem on every deploy. Redis is the standard answer: fast, shared, with expiry built in (Chapter 7.6). A database table works and is slower.

The cookie attributes are the actual security (Chapter 5.6.3 covers each in full):

Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400

HttpOnly keeps JavaScript from reading it, which is what stops an XSS from stealing the session. Secure keeps it off plain HTTP. SameSite=Lax blocks most cross-site requests from carrying it, which removes most CSRF.

Session fixation is the attack where an attacker sets a known session id before login and then uses it afterwards. The fix is one line: generate a new session id on every privilege change — login, elevation to admin, and step-up authentication — and destroy the old one.

Store an absolute maximum lifetime, not only an idle timeout. An idle timeout alone can be kept alive forever by a background poll.

Binding a session to an IP address or user agent is tempting and mostly wrong. Mobile clients change IP constantly, and browsers change user agent on update. You get support tickets, not security. Binding to a coarse signal and using it to raise risk — requiring re-authentication rather than terminating — is the version that works, and it belongs with the adaptive authentication in Chapter 8.4.7.

3. JWT anatomy

A JWT is three base64url segments separated by dots.

eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyXzQyIiwiZXhwIjoxNzU0MTMxMjAwfQ . MEUCIQ…
   header                                payload                                          signature

Header — the algorithm and type.

Payload — the claims:

ClaimMeaning
issIssuer — who made this token
subSubject — usually the user id
audAudience — who it is for
expExpiry (seconds since epoch)
nbfNot valid before
iatIssued at
jtiA unique token id, for blocklisting

Signature — over header.payload.

The single most important fact: a JWT is signed, not encrypted. Anyone holding it can read every claim — paste one into any decoder and the payload appears. Never put anything confidential in a JWT. People routinely include email addresses, roles, internal identifiers and occasionally far worse.

Keep them small. A JWT is sent on every request, in a header, and headers have size limits at proxies (Chapter 9.9.2). Embedding 200 permissions produces a token that is rejected by a load balancer with an unhelpful error.

JWS versus JWE: JWS is signed (the common case). JWE is encrypted, so claims are hidden. It exists, it is used for tokens crossing untrusted intermediaries, and it is far rarer.

4. HS256 versus RS256, and the attacks on alg

HS256 is an HMAC (Chapter 8.2.2): one shared secret both signs and verifies. Fast and simple — and anyone who can verify can also forge, so every service holding the key can mint tokens.

RS256 / ES256 are signatures: the issuer signs with a private key, everyone verifies with the public key. Verifiers cannot forge, which is what makes it right the moment more than one service — or anyone external — validates tokens. ES256 is the elliptic-curve version and produces much smaller signatures.

The rule: HS256 when one service issues and verifies. RS256/ES256 everywhere else. OAuth and OIDC (Chapters 8.4.3 and 8.4.4) use asymmetric signing with keys published at a JWKS endpoint precisely so that resource servers can verify without holding anything secret.

Two classic attacks, both on the alg header:

alg: none. Early libraries honoured a header saying "no signature" and accepted the token. Trivially forgeable.

Algorithm confusion. A server expects RS256 and passes "the key" to a generic verify function. An attacker changes the header to HS256 and signs the token using the public RSA key as the HMAC secret — and the public key is public. The verifier, seeing HS256, uses that same key as the shared secret, and the forgery validates.

Both have one fix: pin the expected algorithm at the verifier and never take it from the token.

ts
const payload = jwt.verify(token, publicKey, {
  algorithms: ['ES256'],                 // (1)
  issuer: 'https://auth.example.com',    // (2)
  audience: 'api.example.com',           // (3)
  clockTolerance: 30,                    // (4)
});

(1) An allow-list, not a value read from the header. (2) and (3) are checked far less often than they should be: without aud, a token minted for a different service in your estate is accepted here, which is a privilege escalation across services. (4) A few seconds of tolerance for clock skew — not minutes, which extends every token's life.

5. Why revocation is hard, and what actually works

Return to the dismissed employee. The server cannot invalidate a token it never stored. Four responses exist and each gives something back.

Short expiry plus refresh tokens — the standard answer. The access token lives 5–15 minutes and is verified locally. A long-lived refresh token is stored server-side and exchanged for new access tokens. Revocation means deleting the refresh token, and the exposure window is the access token's remaining life.

This is the design to reach for, and its honest statement is: revocation is not immediate, it is bounded. If your requirement is genuinely instant, you need session lookup for that operation.

A blocklist of jti values — check every token against a revoked set. It works, and it reinstates the lookup that made JWTs attractive, so you have a session with extra steps. Reasonable when revocations are rare, and the list only needs to hold entries until they expire.

Token introspection — the resource server asks the issuer whether a token is still valid. Fully accurate, fully stateful, one network call per request.

A tokenVersion in the token and on the user — bump the user's version to invalidate everything issued before. It is a single indexed read rather than a full session load, and it is a good middle ground for "log out everywhere".

The conclusion worth stating in an interview: for a first-party web application, server-side sessions are usually the better choice. You get instant revocation, small cookies, no claim staleness, and Redis handles the lookup in under a millisecond. JWTs earn their place when the verifier cannot call the issuer — third-party APIs, service-to-service calls across trust boundaries, and federation, which is where Chapters 8.4.3 and 8.4.4 live.

6. Refresh tokens and theft detection

A refresh token is long-lived and high-value, so it needs its own handling.

Rotate on every use. Each refresh returns a new refresh token and invalidates the old one.

Detect reuse. If an already-used refresh token is presented, either the legitimate client is retrying or a stolen copy is being used — and you cannot tell which. The correct response is to revoke the entire token family and force re-authentication. This is the mechanism that turns refresh-token theft from a silent permanent compromise into a detected event, and it is the strongest argument for rotation.

Store them hashed, exactly like reset tokens. Bind them to a client, and where possible to a device.

Give them an absolute lifetime, not just an idle one, or a stolen token refreshes forever.

7. Where a browser should keep the token

localStorage is readable by any JavaScript on the page, so an XSS takes the token and can exfiltrate it to a server the attacker controls, where it remains usable.

An HttpOnly cookie cannot be read by JavaScript. An XSS can still make requests using it — the browser attaches it automatically — but the attacker cannot take the token away with them.

So the honest comparison: XSS defeats both, and it defeats them differently. With localStorage the attacker walks away with a portable credential; with an HttpOnly cookie they are confined to the victim's browser for as long as the page is open. That difference is worth having, and the cookie is the better default. The cost is that cookies are sent automatically, so you need SameSite and CSRF defences (Chapter 8.5.2).

The pattern that resolves it for single-page applications is a backend for frontend: the browser holds only an HttpOnly session cookie, a small server-side component holds the actual tokens and attaches them to API calls. The browser never sees a token at all. It costs one component and removes the entire category.

Never put a token in a URL. It reaches server logs, proxy logs, browser history and the Referer header (Chapter 5.7).

8. Migrating from sessions to tokens

Do it gradually and both-ways-compatible:

  1. Keep issuing sessions. Add token issuance alongside.
  2. Make the API accept either a session cookie or a bearer token.
  3. Move clients over one at a time.
  4. Only then stop issuing sessions.

And ask why first. "Statelessness" is usually not the reason — a Redis lookup is sub-millisecond. The reasons that hold up are: multiple independent services must verify without calling you, a third party must consume the identity, or you are adopting OIDC and the token is the protocol's currency. Migrating for architectural fashion costs you instant revocation and buys nothing measurable.

What the interviewer will push on

"Sessions or JWTs?" Sessions for a first-party web application — instant revocation, small cookie, no staleness, sub-millisecond lookup. JWTs when the verifier cannot call the issuer: third-party APIs, cross-boundary service calls, federation. Anyone answering "JWT because stateless scales better" without pricing revocation has not run one.

"How do you revoke a JWT?" You cannot, directly. Bound the damage instead: short-lived access tokens with server-side refresh tokens, so revocation deletes the refresh token and exposure is the access token's remaining life. Then list the alternatives and what each costs — a jti blocklist reintroduces the lookup, introspection is a call per request, a tokenVersion is one indexed read.

"What is the difference between HS256 and RS256?" Shared secret versus key pair. With HS256 anyone who can verify can also forge, so it only fits a single service that both issues and verifies. RS256/ES256 lets many services verify with a public key, which is why OAuth and OIDC publish a JWKS endpoint.

"What is the algorithm confusion attack?" The server expects RS256 and the attacker sets the header to HS256, signing with the public key as the HMAC secret. The verifier then uses that same public value as a shared secret and the forgery passes. The fix is pinning an algorithm allow-list at the verifier rather than trusting the header — and the same fix kills alg: none.

"Where do you store a token in the browser?" An HttpOnly cookie, because XSS defeats both options but only localStorage hands the attacker a portable credential. Then volunteer the backend-for-frontend pattern, where the browser holds a session cookie and never sees a token at all.

"How do you handle a stolen refresh token?" Rotate on every use and detect reuse: presenting an already-used token means either a client retry or a theft, and since you cannot distinguish them, revoke the whole family. That detection is the main reason rotation exists, and stating it is what separates a considered answer.

One thing to volunteer: point out that almost nobody validates the aud claim, so a token minted for one service in an estate is happily accepted by another — a privilege escalation with no exploit code required. It is one line of verification configuration and it is missing far more often than it is present.

Recall

  • Two shapes of proof: a reference (session id, looked up) or a claim (signed token, verified locally). Every difference follows from whether the server looks something up.
  • Session ids are 128+ bits of CSPRNG with no meaning; store server-side (Redis, not process memory); cookie carries HttpOnly; Secure; SameSite. Rotate the id on every privilege change to kill session fixation, and set an absolute lifetime as well as an idle one.
  • A JWT is signed, not encrypted — anyone holding it reads every claim. Keep it small; it travels on every request and proxies limit header size.
  • HS256 = shared secret, so any verifier can forge. RS256/ES256 lets many services verify safely — which is why OIDC publishes public keys at a JWKS endpoint.
  • Pin the algorithm at the verifier. alg: none and algorithm confusion (signing with the public RSA key as an HMAC secret) both die to an allow-list. Validate iss and aud — an unchecked audience accepts another service's token.
  • Revocation is bounded, not immediate: short access tokens plus server-side refresh tokens. A jti blocklist or introspection reinstates the lookup; a tokenVersion is the cheap middle ground.
  • Rotate refresh tokens and detect reuse — a replayed token means retry or theft, and since you cannot tell, revoke the family. Store them hashed with an absolute lifetime.
  • HttpOnly cookie over localStorage: XSS beats both, but only localStorage yields a portable credential. A backend for frontend removes tokens from the browser entirely.

Self-test: What exactly does a JWT hide? · Why can't HS256 be used when three services verify tokens? · Walk the algorithm confusion attack · What does refresh-token reuse detection actually detect? · Why is aud validation a privilege-escalation defence? · Why does XSS hurt localStorage more than an HttpOnly cookie?

Next: 8.4.3 covers the protocol that lets one system act on a user's behalf at another without ever seeing their password — OAuth 2.0, every flow, and what PKCE actually prevents.