Lesson 1 of 5
Sessions, Tokens & JWT
"Any party in possession of a bearer token (a 'bearer') can use that token to get access to the associated resources (without demonstrating possession of a cryptographic key)." — RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage (2012)
That sentence is the whole security model, and it is worth reading twice. A bearer token is a house key with the address written on it. There is no challenge, no proof, no second factor at the point of use — whoever holds the string is the user, and every design decision in this chapter is about limiting the damage that follows from that being true.
Authentication answers who is calling; authorization, which is the next chapter, answers what they may do. This chapter is entirely about the first, and specifically about the credential: what it is, what the server has to remember, and what happens when it is stolen — because it will be, eventually, by an XSS bug, a leaked log, a compromised laptop, or a browser extension.
There are two families, and the choice between them is a single trade stated plainly: a session is a reference, a token is a copy. A session identifier is a meaningless random string that points at state on your server; a token carries the state itself, signed. The first requires a lookup on every request and can be revoked instantly. The second requires no lookup and cannot be revoked at all. Almost everything written about "stateless authentication" presents the second as an upgrade. It is a trade, and this chapter is about which side of it you are actually on.
If a JWT cannot be revoked before it expires, and the standard fix is a server-side list of revoked tokens checked on every request — at what point have you rebuilt sessions with extra steps, and what exactly did the statelessness buy?
A reference or a copy
Both designs authenticate the same request. The difference is where the truth lives, and it shows up immediately in two operations: verifying a request, and logging someone out.
Click Run to execute the code.
That last table is the entire decision. Sessions cost a lookup per request and give you instant revocation, role changes that take effect immediately, and a single place to see who is logged in. Tokens cost nothing per request and give you none of those things.
Which is right depends on a question people rarely ask out loud: how quickly must a compromised credential stop working? If the answer is "immediately" — a banking application, an admin console — you want sessions, or something that behaves like them. If the answer is "within fifteen minutes is fine", short-lived tokens are genuinely cheaper. The mistake is choosing tokens for the architecture diagram and then discovering the requirement later.
A JWT is signed, not secret
Before going further: the single most common misunderstanding about JWTs, and the one that puts real data on the wire.
Click Run to execute the code.
base64url is an encoding. Every claim in a JWT is readable by anyone holding the token, and the token travels through proxies, browser storage, error trackers and log aggregators. Putting an email address in there is a data-protection question; putting an internal customer tier or an impersonation flag in there is a disclosure.
The signature protects against modification, and it does that job well — flipping role to superadmin invalidates it. It does nothing whatsoever about reading. If a claim must be secret, it does not belong in a JWT; it belongs behind a lookup, which is to say, in a session.
A cloakroom ticket versus a laminated badge. The ticket says 47 and means nothing on its own — the coat it refers to is in the cloakroom, and if you report it lost the attendant crosses out 47 and it stops working instantly. That is a session: a reference, worthless if stolen once revoked, but requiring an attendant who remembers. The badge has your name, your department and your access level printed on it, sealed under a hologram nobody can forge. The guard can check it without phoning anybody, which is wonderfully efficient and works at a hundred doors at once. But if you lose it, the badge does not know. It keeps working until its printed expiry date, and the only remedy is to give every guard a list of badges not to trust — at which point the guards are phoning somebody again, which was the thing the badge was supposed to avoid. Notice also that the badge is readable: anyone who picks it up learns your department and your clearance. The hologram stops forgery, not reading.
The two attacks everyone should be able to describe
JWT libraries have a history, and it is instructive because both famous failures come from the same design decision: the token tells the verifier how to verify it.
Click Run to execute the code.
Both attacks are historical facts rather than hypotheticals. alg: none was in the specification as an "unsecured JWT" and multiple libraries honoured it by default; algorithm confusion works because HMAC takes an arbitrary byte string as its key, and an RSA public key is an arbitrary byte string that the attacker already has.
The lesson generalises past JWT: never let untrusted input decide how it will be validated. The header of a token is supplied by whoever sent the token. A verifier that reads alg from it and dispatches accordingly has asked the attacker which door to check.
A valid signature is not a valid token
The signature says the claims are unmodified. It says nothing about whether they still apply, or whether they were meant for you.
Click Run to execute the code.
The aud check is the one most often skipped and the most interesting. In an organisation with several services sharing an identity provider, a token issued for the low-privilege billing service verifies perfectly against the main API's key if that key is shared — so a compromise of the least important service becomes a compromise of everything. Checking aud is what keeps them separate.
Note also leeway. Clocks on different machines disagree by seconds, and a token rejected because the issuer's clock is two seconds ahead produces intermittent login failures that are miserable to diagnose. Thirty seconds of tolerance is standard.
Cookie-based sessions have been the web's default since Netscape introduced cookies in 1994, and for two decades "authentication" in a web framework meant a session table and a signed cookie. What changed the fashion was not a flaw in that model but a change in architecture: single-page applications calling APIs from JavaScript, mobile clients that have no cookie jar in the browser sense, and services split across many deployments that did not share a session store. JWT was standardised as RFC 7519 in 2015, alongside the wider JOSE family, and offered exactly what that architecture wanted — a credential any service could verify with a shared key and no coordination. Then the failures arrived, and they arrived quickly. Tim McLean's 2015 write-up of the alg vulnerabilities — none and RS256-to-HS256 confusion — showed that a specification letting the token choose its own verification algorithm was a footgun that many libraries had already fired. Auth0's 2016–2017 advisories added more. The response was RFC 8725, "JWT Best Current Practices" (2020), which reads in places like a list of regrets: always specify the algorithms you accept, always validate aud and iss, do not use a token as a session, do not put secrets in the payload. The pendulum has since swung part-way back — a good deal of current advice is that if you have one service and a browser, a session cookie remains the simplest secure answer, and the JWT is for the cases that genuinely span trust boundaries.
Short access, long refresh, rotated
The practical resolution of the revocation problem is not to choose one model but to split the credential in two: a short-lived token that is never revoked because it barely lives, and a long-lived one that is stored server-side and therefore can be revoked.
Click Run to execute the code.
Each half does a job the other cannot. The access token is checked on every request and needs no lookup, because its short life is the security control. The refresh token is checked rarely — once per fifteen minutes rather than once per request — so a store lookup there is affordable, and that lookup is what makes revocation real.
Rotation with reuse detection is the part worth understanding properly. Each refresh invalidates the token that was presented and issues a successor. If an old one is ever presented again, exactly two explanations exist: the legitimate client retried, or somebody stole a copy. You cannot distinguish them, so you revoke the whole family and force a fresh login. That is a small annoyance for a user with a flaky connection and the only mechanism in this chapter that detects a compromise rather than merely limiting it.
Decisions to make once. If you have one backend and a browser, use a session cookie. It is revocable, the credential never touches JavaScript, and the whole class of token-storage arguments does not arise; reach for tokens when you genuinely cross a trust boundary. Never store a credential in localStorage — it is readable by any script on the page, so one XSS is a permanent account takeover; HttpOnly; Secure; SameSite=Lax cookies are the default, with the CSRF consequences handled in the next chapter. Rotate the session identifier on login, or an attacker who plants a known identifier before you authenticate is holding a valid session afterwards — session fixation, still shipping in 2026. Never put a token in a URL: it lands in access logs, browser history, and the Referer header of every outbound link. Always pass the expected algorithms explicitly to your JWT library, and validate exp, aud and iss with about thirty seconds of clock leeway. Keep the payload free of anything you would not publish — it is readable by whoever holds it. And size the access-token lifetime by how long you are willing to stay compromised, because that number, not the algorithm, is the actual security parameter.
The revocation window is the security parameter
Everything above converges on one number. When a credential is stolen, how long does the attacker have?
Click Run to execute the code.
The right-hand column is the argument. A fifteen-minute access token gives an attacker eighteen thousand requests at a modest rate; a thirty-day "remember me" JWT gives them fifty-two million. Both are "just a token"; they are different products.
And the denylist row is the punchline of the chapter. Adding a revocation list to JWTs restores the per-request lookup, the server-side state and the shared store — every property statelessness was supposed to eliminate — while keeping the signature verification and the specification risk. That combination is occasionally the right answer, when tokens must cross services and revocation must be immediate. It is very often adopted without anyone noticing the trade has been reversed.
A pattern that recurs across incident reports: a team builds a single-page application, chooses JWTs because the architecture blog posts said stateless, and stores the token in localStorage because that is the easiest thing that works. Two decisions, each defensible in isolation, that compose badly. Later an XSS gets in — through a dependency, a rich-text field, a third-party widget — and the payload does not need to do anything clever, because the credential is sitting in a JavaScript-readable store, so it simply reads it and posts it elsewhere. Now the second decision compounds: the token cannot be revoked, and it was given a long lifetime precisely to avoid the friction of frequent logins. The XSS is patched within hours; the stolen tokens keep working for their full remaining life, and the only way to invalidate them is to rotate the signing key, which logs out every user of the product at once — an incident on top of an incident, requiring approval nobody wants to give at 2 a.m. The instructive part is that the fix is not exotic. An HttpOnly cookie would have made the token unreadable by the injected script, and a fifteen-minute access token with a revocable refresh would have capped the exposure at fifteen minutes. Both were available on day one and both lost to convenience, which is how most of these end.
Common Mistakes
Choosing tokens for the architecture and discovering the revocation requirement later. "Log this user out now" and "revoke this API key immediately" are product requirements, and a plain JWT cannot satisfy either.
Putting anything private in a JWT payload. It is base64, not encryption — readable by every proxy, log aggregator and error tracker the token passes through, and by anyone who steals it.
Letting the token choose its verification algorithm.
alg: noneand RS256-to-HS256 confusion both come from a verifier that trusts the header. Always pass the expected algorithms explicitly.Verifying the signature and stopping. A perfectly signed token can be expired, issued for a different service, or minted by a different issuer. Validate
exp,audandiss, with clock leeway.Skipping the
audcheck when services share a key. A token for the least important service then opens the most important one, so one weak service compromises everything.Storing credentials in
localStorage. Any script on the page can read it, so a single XSS becomes account takeover — and with a long-lived token, permanent account takeover.Long-lived access tokens. The lifetime is the security parameter. Thirty days of validity is thirty days of unrevokable access to whoever steals it.
Refresh tokens without rotation and reuse detection. Rotation alone limits damage; reuse detection is the only mechanism here that tells you a theft happened at all.
Not rotating the session identifier on login. An attacker who fixes a known identifier before authentication holds a valid session after it. Session fixation is old and still shipping.
Adding a denylist and still calling it stateless. It reinstates the per-request lookup and the shared store, which is a session with a signature attached — fine if chosen deliberately, common by accident.
Interview Corner
alg: none case has the token declare it is unsigned, and libraries that honoured that accepted anything. The subtler one is algorithm confusion: if the verifier sees RS256 it uses the RSA public key, but if an attacker rewrites the header to HS256 and signs with HMAC using that public key as the secret, a verifier that dispatches on the header will validate it — and the public key is published, so the attacker needs no secret at all. So: fixed list of accepted algorithms, always. After the signature I check the claims, because a valid signature only means the claims were not modified, not that they still apply. exp, with a little clock leeway, and I would reject a token with no expiry outright. aud, so a token minted for another service does not work here — that one matters a lot when several services share an identity provider, because otherwise compromising the least important one compromises all of them. iss, so only my identity provider can mint users. And nbf if present. Then, separately from validation, I would look at what is in the payload, because it is readable by anyone holding the token, so an email address or an internal tier in there is a disclosure.HttpOnly; Secure; SameSite cookies, not localStorage, so an XSS cannot read them — with CSRF handled separately, which is a trade I would make explicitly. Mobile uses the platform keystore. Across services, each service validates aud for itself so a token for the notifications service cannot call payments, and I would prefer asymmetric signing with a published key set so services verify without holding a secret that lets them mint tokens. And I would keep the payload to an identifier and coarse claims, because it is readable in transit and at rest — anything sensitive stays behind a lookup.Build the verifier and the refresh flow. issue and verify must produce and check an HS256 token — and verify must decide the algorithm itself, so the two forged tokens in the checklist are refused, then validate exp, aud and iss so that a perfectly signed token can still be rejected. rotate must exchange a refresh token for a successor and detect reuse by revoking the whole family. The checks that matter are the four forgery and replay ones: a valid signature is not a valid token, and a replayed refresh must lock everybody out. The checklist reports each requirement as PASS, FAIL or ERR; run it before writing anything and watch the lines flip.
Click Run to execute the code.
Summary
A bearer token is a key with the address on it. Whoever holds the string is the user, so every control in this chapter is about limiting the consequences of that being true.
A session is a reference; a token is a copy. The reference costs a lookup and can be destroyed; the copy costs nothing and cannot, because revocation is not something you can do to a copy.
A JWT is signed, not encrypted. Every claim is readable by anyone holding it — proxies, log aggregators, error trackers, thieves. Nothing private belongs in the payload.
Never let the token decide how it is verified.
alg: noneand RS256-to-HS256 confusion both exploit a verifier that trusts the header; both forgeries need no secret at all.A valid signature only means the claims were not modified. Validate
exp,audandisswith clock leeway — and refuse a token that has no expiry.Check
audespecially when services share a key, or a token for the least important service opens the most important one.Short access plus revocable refresh is the practical answer. The access token's lifetime is the security control; the refresh token is rare enough that a store lookup — and therefore real revocation — is affordable.
Rotation with reuse detection is the only mechanism here that detects a theft. A replayed refresh token means one of two holders is a thief, and since you cannot tell which, you revoke both.
localStorageis readable by any script on the page. AnHttpOnlycookie is the default for browsers; the CSRF consequences are the next chapter's problem and a trade worth making explicitly.The lifetime is the security parameter. Fifteen minutes is eighteen thousand requests for an attacker; thirty days is fifty-two million. Adding a denylist to fix that reinstates every property statelessness removed.
Knowing who is calling is the smaller half. The next chapter, Permissions & Object-Level Access, is about what they may touch: why a permission check that lives on the view is one URL away from being bypassed, how object-level authorization differs from role checks, why the most common API vulnerability in the world is simply changing an id in a URL, and how to make the safe thing the default rather than something each endpoint has to remember.
The word "stateless" has done a remarkable amount of damage in this area, and I think it is because it names a property of the server while the interesting question is about the credential. A JWT does not eliminate state; it relocates it into the client's hands, where you can no longer change it. That is sometimes exactly right — a token crossing a trust boundary between organisations, where a shared session store is not on offer, is a genuinely good use of the format. But the property being sold in most blog posts is horizontal scalability, and for the overwhelming majority of applications the session store was never the bottleneck. What was actually purchased, at the price of revocation, was the removal of a Redis lookup measured in single-digit milliseconds. What I find worth noticing is the shape of the mistake rather than the specifics, because it recurs: a technique that solves a real problem at large scale is adopted at small scale for its association with large scale, and the cost — which was affordable in the original context, where teams also built the compensating machinery — arrives without the compensation. Caching without invalidation, microservices without service ownership, eventual consistency without reconciliation, stateless auth without a revocation strategy. The tell is always the same. When you adopt the technique, ask which problem of theirs you have, and which of their compensating mechanisms you are quietly declining to build.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.