A signed token carries its own contents, so any server holding the key can verify it without asking a database anything.
The previous lesson built a session as a meaningless identifier naming a row on the server, and noted that the lookup is the price of instant revocation. This lesson is about the trade that removes the lookup. If you have arrived here directly, the two ideas you need are that a MAC is a keyed hash proving that a holder of the key produced a message, and that a bearer token is one whoever holds it can spend.
Inverting the indirection
The session design points from the browser to the server: the browser holds a name, the server holds the facts. Invert it and the browser holds the facts, with a signature proving the server wrote them.
const claims = { sub: "4192", role: "editor", exp: 1772621264 };
const token = claims + "." + hmac(key, claims); // schematicallyAny server with the key can now check the signature and read sub and role straight out of the token. No store, no lookup, no shared database between regions. That is a real and substantial benefit, and it is why the pattern exists.
Everything else in this lesson follows from one observation: the server no longer has any say. It signed the token once and let go. There is nowhere to write "this one is finished", because the only record of the session is in the attacker's hands.
What a JWT actually is
A JSON Web Token, as defined in RFC 7519, is three base64url segments joined by dots. Take a real one apart.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MTkyIiwi...ifQ.dBjftJeZ4CVP...The first segment is the header, and eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 decodes to {"alg":"HS256","typ":"JWT"}. The second is the payload, a JSON object of claims. The third is the signature over the first two segments joined by a dot.
Decoding takes one line, and the point cannot be overstated:
const payload = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));A JWT with a signature is signed, not encrypted. Every claim in it is public to anyone holding the token, which includes the user and anyone who steals it. Encrypted tokens exist under a different standard, JWE, and are rare in practice. Put nothing in a JWT you would not print on the page.
The registered claims are worth knowing by name because verification means checking them, not merely checking the signature. iss names the issuer, sub the subject, aud the intended audience, exp the expiry, nbf the not-before time, iat the issued-at time, and jti a unique identifier for the token. A verifier that checks the signature and ignores aud will accept a valid token minted for a different service. A verifier that ignores exp accepts a token from last year. The signature says "we wrote this"; the claims say "and we meant it for you, now".
Size is worth a moment too. A modest payload with issuer, audience, subject, timestamps, an email and two roles comes to 242 base64url characters, and the whole HS256 token to 323 bytes, against 43 for a session token of 32 random bytes. That is 7.5 times larger on every request, and a page issuing a hundred requests carries an extra 28 kB of header traffic. With RS256 the signature alone is 342 characters. This is not usually decisive, but "stateless is free" is not true either.
Example. A team stores {"sub":"4192","plan":"free","internalNotes":"flagged for review"} in a JWT and sets it as an HttpOnly cookie, reasoning that HttpOnly keeps the contents away from the user. What is wrong?
HttpOnly stops page JavaScript reading the cookie. It does nothing about the user, who can open developer tools, copy the cookie value out of the network panel, and paste it into any of a hundred JWT decoders. The internalNotes claim is public. So is plan, which is fine, and so would be anything else put there. HttpOnly is a defence against injected script, not a confidentiality boundary against the token's own holder.
Now you. A service verifies incoming JWTs by checking the HMAC signature with the correct shared key, and nothing else. Two internal services, a billing API and an admin API, share that key. What can a user of the billing API do?
Answer
Take the token the billing API issued them and present it to the admin API, which will verify the signature successfully because the key is the same. The aud claim exists precisely to stop this, and the admin API is not checking it. Two fixes, both worth doing: give each service its own key or key pair so a signature from the wrong issuer fails outright, and check aud against the service's own identifier on every verification. The general rule is that a signature check is necessary and never sufficient.
Two ways the verification is skipped entirely
JWT has produced a family of vulnerabilities that share a shape: the token tells the verifier how to verify it, and the verifier believes it.
alg: none. The standard defines a "none" algorithm for tokens whose integrity is protected some other way. An attacker takes a valid token, edits the payload to {"sub":"1","role":"admin"}, sets the header to {"alg":"none"}, drops the signature, and leaves a trailing dot. A library that reads alg from the header and dispatches on it will take the "none" branch, find nothing to verify, and return the claims as valid. This was widespread in 2015, most libraries now refuse none by default, and it still appears in code that calls a generic decode function and treats the result as verified.
Algorithm confusion. A service issues RS256 tokens signed with its private key, and verifiers check them with the public key, which is public. The attacker changes the header to {"alg":"HS256"}, signs the token with the public key bytes used as an HMAC secret, and submits it. A verifier that reads alg from the header and passes both the token and "the key" to a generic function may hand the RSA public key to the HMAC path, compute the same MAC the attacker did, and accept it. The attacker forged a token using only public information.
Both have the same root cause and the same fix: the verifier decides the algorithm, not the token. Pin it in the code.
const parts = token.split(".");
const header = JSON.parse(atob(parts[0]));
if (header.alg !== "HS256") throw new Error("unexpected alg");Reading alg at all is only to reject it. The key you verify with was chosen before the token arrived.
Two smaller traps belong here. kid, the key identifier, is attacker-controlled, and implementations that use it to build a file path or a SQL query have been exploited through path traversal and injection; treat it as an untrusted lookup key into a fixed set. And exp is seconds since the epoch, not milliseconds, so a verifier comparing it against Date.now() accepts tokens for the next fifty thousand years.
Revocation, and why short expiry does not fix it
Here is the cost. A user's laptop is stolen, or an administrator revokes access, or a session is discovered to be an attacker's. With server-side sessions you delete a row and it is over. With a signed token there is no row. The token is valid because the mathematics says so, and it will stay valid until exp.
The standard answer is a short expiry: fifteen minutes, so the damage window is bounded. Look at what that actually says. Revocation now takes up to fifteen minutes and averages 7.5, during which the attacker uses the account freely. If you can tolerate that, fine, and be clear that it is what you have chosen. If the account can move money or delete data, a mean of seven and a half minutes of attacker access after you pressed the button is not an acceptable answer.
The other answer is a denylist: record revoked jti values and check every token against the list. This works, and it is worth being blunt about what it means. You have reintroduced the lookup on every request, so the statelessness is gone, and you now maintain both a signing key and a store. The one thing you keep is that the store holds only revoked tokens rather than all live ones, which is a smaller table but the same architecture. A denylist plus short expiry is a defensible design; a denylist plus a claim of statelessness is not.
There is a subtler version of the same problem that catches more teams than theft does. Claims go stale. If the token carries role: "editor" and an administrator demotes that user, every server keeps reading editor from the token until it expires. Permission changes, plan downgrades, disabled accounts and team removals all inherit the revocation delay. A session that reads the user row on each request has no such window, and this is the everyday cost rather than the dramatic one.
Example. A team uses 15-minute access tokens with a jti denylist checked on every request, and describes the design as stateless. Where has the state gone, and what have they actually gained over sessions?
The state is the denylist, consulted on every request exactly as a session table would be, so the architecture is identical: a store, a lookup, an availability dependency. What they gained is that the store holds only revoked tokens, so it is smaller and can be an in-memory set with a fifteen-minute time-to-live rather than a durable table. What they lost is that claims inside the token can be stale even when the token is not revoked, so a demotion still takes up to fifteen minutes. For a single application this is a worse session table. For a fleet of services that each need to verify without reaching a shared database, the trade may be worth it.
Now you. A JWT carries role and the application checks it on each request. An administrator removes a user's admin role at 10:00. The token was issued at 09:52 with a one-hour expiry. Until when does that user remain an administrator, and what change fixes it?
Answer
Until 10:52, when the token expires: the demotion is written to the database and no server ever reads it, because the role comes from the token. Revoking by jti fixes it only if the demotion path knows to revoke, which it usually does not. The reliable fix is to stop carrying authorization data in the token. Let the token say who the user is and read what they may do from the database on each request, which is the last lesson's argument, and costs exactly the lookup that the token was meant to avoid.
Refresh tokens and the rotation dance
Short access token lifetimes create a new problem: the user must not be signed out every fifteen minutes. The standard structure is two tokens. A short-lived access token is sent with each request and is not stored durably. A long-lived refresh token is stored, sent only to one endpoint, and exchanged for a new access token when the old one expires.
Notice what has happened. The refresh token is long-lived, so it must be revocable, so it is stored server side and looked up. It is a session, wearing different clothes. That is not a criticism, it is the point: the stateless part is pushed to the short-lived token where its cost is bounded, and the durable part keeps the property that only a stored credential can have.
Because the refresh token is the valuable one, it gets its own defence, rotation with reuse detection. Each refresh returns a new refresh token and invalidates the old one, and the old one is remembered rather than forgotten. If a token that has already been used is presented again, that is evidence of theft: either the attacker or the legitimate client is using a copy. Since the server cannot tell which, it revokes the whole token family and forces a fresh login. This is the mechanism recommended in the OAuth security best current practice, RFC 9700, and it converts silent long-term theft into a detectable event with a bounded window.
The failure mode to design around is a legitimate client racing itself: two tabs refresh at once, the second presents a token the first just consumed, and the family is revoked for no reason. A short grace period, a few seconds during which the immediately previous token is accepted and returns the same new pair, handles this without weakening the detection meaningfully.
Example. An attacker steals a refresh token at 12:00. The legitimate user next refreshes at 12:20. Walk through what rotation with reuse detection produces, and what the attacker got.
Suppose the attacker refreshes first, at 12:05: they receive a new access and refresh pair, and the stolen token is now marked used. At 12:20 the legitimate client presents the same stolen-and-now-used token, the server sees reuse, revokes the entire family, and the user is asked to sign in again. The attacker held access for fifteen minutes and their refresh token dies too. Had the user refreshed first, the attacker's later attempt would trigger the same revocation and they would get nothing. Either way, theft becomes a detectable event with a bounded window instead of indefinite silent access. What it does not do is prevent the fifteen minutes.
Now you. Why is it a mistake to store a refresh token in localStorage even though the access token is short-lived?
Answer
The refresh token is the durable credential, so putting it where any script on the origin can read it means one XSS bug yields long-term access rather than fifteen minutes of it. Rotation helps a little, because the attacker's use will eventually collide with the user's and trigger revocation, but an attacker who refreshes constantly keeps a live family and may simply outrun the user. A refresh token belongs in an HttpOnly cookie scoped with Path to the refresh endpoint alone, which is the same conclusion the sessions lesson reached, for the same reason.
Where stateless tokens genuinely win
The criticism above is aimed at one specific use, browser login for a single application, where a session cookie is simpler and strictly better. There are cases that pull the other way, and they share a shape: many verifiers, no shared store, short lifetimes tolerable.
Service to service. An internal service receiving a request from another service needs to know the caller and its scopes. There is no browser, no CSRF, no user to sign out, and a token verified against a public key with no network round trip is exactly right.
A fleet of APIs behind one identity provider. Half a dozen services in different regions each verifying a token against a published key set avoid a shared session database and its latency. The revocation delay is a known cost, priced in.
Edge and serverless verification. A CDN worker deciding whether to serve a cached response cannot reach your database in a useful time. Signature verification takes microseconds.
As the output of OAuth. The ID token in OpenID Connect is a JWT because it must be verifiable by a party that cannot query the issuer's database. That is exactly the problem JWTs solve, and the ninth lesson uses one.
The pattern common to all four is that the token crosses a trust boundary between systems. Within one application, talking to one browser, the boundary is not there and the token is solving a problem you do not have.
So the honest summary: for browser login on an ordinary web application, use a session cookie. Reach for a signed token when verification must happen somewhere that cannot reach your store, and when you have written down what your revocation delay is and decided you can live with it.
What the browser does with either
Session cookie or JWT in a cookie, the situation is now the same in one important respect. A bearer secret lives in the user's browser, and it is attached to requests. Everything that follows depends on two facts about browsers that have nothing to do with which token format you chose.
The first is that a browser sends cookies for your site on requests your site did not initiate, which lets an attacker's page cause an authenticated action. The second is that a browser executes any script it believes came from your origin, which lets an attacker who can inject script act as the user directly, whatever attributes your cookie carries.
Those are cross-site request forgery and cross-site scripting, they are the two attacks that actually take sessions in practice, and they are next.