JSON Web Tokens
What a JWT proves and who can read it, the verifier mistakes behind real attacks, expiry, refresh and revocation, where to keep tokens in the browser, and key rotation, each with a live demo.
A JSON Web Token is a signed statement a server hands to a client and later trusts because it recognises its own signature, without looking anything up. That makes JWTs convenient and easy to get subtly wrong: the token is readable by anyone, and its safety rests entirely on the verifier doing every check. This page builds that model and then breaks it: the tokens below are really signed by this site's server, the attacks run against a real verifier, and the storage demos run real scripts. It sits after Origins, DNS and CORS.
What a JWT is, and what signing proves
A signed JWT is three base64url parts joined by dots: header.payload.signature. The header and payload are just JSON anyone can read; the signature is the only part that needs a key, and it covers the first two parts exactly.
- Signed, not encrypted. The payload is public. Put an id and a few claims in it, never a password, card number or anything secret.
- The signature authenticates, it doesn't hide. It proves the token was made by someone with the key and hasn't changed since. Change one character of the payload and it no longer matches.
- HMAC vs public-key.
HS256uses one shared secret, so every service that can verify can also forge;RS256andES256sign with a private key and verify with a public one, so verifiers never hold signing power.ES256gives the same guarantee asRS256in a far smaller token. - Registered claims carry the rules:
iss,sub,aud,exp,nbf,iat,jti. The header'salgandkidsay how and with which key it was signed.
Try it: issue a token, read it, tamper with it
1. Ask the server for a token
Letters, digits and . @ - _ (up to 40).
2. Read it (decoded here in your browser, no key needed)
3. Change it, then let the server verify it
What is signed (simplified)
signature = sign( base64url(header) + "." + base64url(payload), key )
The signature covers the exact text of the first two parts, so changing one character anywhere in them breaks it. Decoding needs no key; only creating a valid signature does.
- HS256: 32 bytes: an HMAC-SHA256 with one shared secret. Anyone who can verify can also sign.
- RS256: 256 bytes with a 2048-bit RSA key. The private key signs; the public key only verifies.
- ES256: 64 bytes (r and s on curve P-256). Same private/public split as RSA, far smaller.
Do: Treat the payload as public. Keep tokens small and pick
ES256overRS256for new systems.Avoid: Storing anything secret or sensitive in the payload, or assuming base64url means it is hidden.
Read more:RFC 7519: JWT(opens in a new tab)jwt.io: anatomy(opens in a new tab)MDN: Base64(opens in a new tab)
Verifier mistakes behind real attacks
A JWT is only as safe as the code that verifies it. Almost every real-world JWT incident is a verifier that trusted the token too much — letting the token pick its own algorithm or key, or skipping a claim check. Here a naive verifier and a strict one face the same forged tokens.
- Pin the algorithm on the server. Never let the token's
algdecide. This is what stops bothalg: noneand the classic RS256→HS256 key confusion, where a forger signs with the public key as an HMAC secret. - Only trust keys you configured. Ignore
jwk,jkuandx5uin the header; look the key up bykidin your own set. A key the token brings is no key at all. - Check the claims. A perfect signature says nothing about when or who for: always verify
exp/nbf,issandaud. A valid signature on a token meant for another API, or long expired, must still be rejected. - Turn the strict verifier's checks off one at a time in the demo: each attack is let through by exactly one missing check.
Try it: run each attack past a naive and a strict verifier
Mallory's attack
Mallory writes her own payload with role "admin", sets the header to alg "none" and sends no signature at all.
The strict verifier's checks
The naive verifier has every check off: it only verifies a signature, with whatever algorithm and key the token asks for.
Do: Use a vetted library, pass it an explicit allow-list of algorithms, and verify
issandaudon every request.Avoid: Hand-rolling verification, calling
decodewhere you meantverify, or passing a verifier a key without fixing the algorithm.
Read more:RFC 8725: JWT best practices(opens in a new tab)OWASP: JWT cheat sheet(opens in a new tab)
Expiry, refresh and revocation
A stateless token can't be called back, so the usual design is a short-lived access token (minutes) plus a long-lived refresh token that buys new ones. The hard parts are expiry, rotating refresh tokens safely, and actually revoking access.
- Keep access tokens short.
expis the main defence for a token that can't be revoked. Allow a few seconds ofleewayfor clock skew — seconds, not minutes. - Rotate refresh tokens and detect reuse. Each refresh issues a new refresh token and retires the old one. If a retired one is ever presented again, treat it as theft and revoke the whole family — you can't tell the thief from the victim, so you log out both.
- Refresh tokens needn't be JWTs. An opaque random string stored server-side (as a hash) is easy to revoke and reveals nothing.
- Revocation needs state. To cut a valid access token off before
expyou need a deny-list of token ids or a per-user token version checked on each request. With neither, a stolen access token works until it expires — which is the whole reason to keep it short.
Try it: sign in, let it expire, refresh, and replay a stolen token
Server settings
Do: Short access tokens, rotating refresh tokens with reuse detection, and a revocation mechanism for the paths that need it.
Avoid: Long-lived access tokens, reusing a refresh token, or assuming "log out" invalidates a JWT already in someone's hands.
Read more:RFC 8725 §3.2–3.3(opens in a new tab)OWASP: session management(opens in a new tab)
Where to keep a token in the browser
Where a token lives in the browser decides who can steal it. The real trade-off is between XSS (a script running in your page) and CSRF (another site making requests as you) — each storage choice trades one risk for the other.
localStorage/sessionStorage: any script on the page reads them, so an XSS bug exfiltrates the token outright. No cookie means no CSRF, but the exposure to XSS is the worst case.- An
HttpOnlycookie can't be read by JavaScript, so XSS can't steal the token — but the browser attaches it automatically, so you needSameSite(and CSRF defences) to control cross-site sends. Note XSS is still game over: a script can still make authenticated requests, it just can't take the token away. - In memory (a variable, not storage) survives neither a reload nor XSS reading it — but a script can still patch
fetchand ride along on the next call. Nothing in the page truly resists a script running in the page, which is why stopping XSS comes first. - Sessions and the BFF. A plain server session (an opaque
HttpOnlycookie, state on the server) is often the simplest safe choice for a first-party web app. A backend-for-frontend keeps the real tokens on the server and gives the browser only a session cookie — the browser never holds a JWT at all.
Try it: store a token, then run an injected script and a cross-site request
1. Where does the token live?
2. Attack it
Do: For a first-party app, prefer a server session or a BFF with an
HttpOnly,SameSitecookie. Whatever you choose, stop XSS first — it defeats every option.Avoid: Putting a long-lived token in
localStorageand treating the storage question as a substitute for a Content-Security-Policy.
Read more:OWASP: XSS prevention(opens in a new tab)MDN: SameSite cookies(opens in a new tab)
Keys, kid and rotation
An issuer publishes its public keys as a key set (JWKS) and names the signing key in each token's kid. That lets verifiers find the right key, and lets the issuer rotate keys without downtime.
- Publish only public keys. A JWKS lists each key's public parts, its
kidand its algorithm — never private material. - Rotate by overlap. Add a new key and publish it; switch signing to it; keep verifying the old key until every token it signed has expired; then retire it. A token signed by a retired key fails with "unknown key id".
- Verify by
kid, pinned to that key's algorithm. This is the same rule as the attacks topic: the key set, not the token, decides which algorithm is acceptable.
Try it: rotate the signing key and verify old and new tokens
The published key set (JWKS)
Only public keys, each with a key id (kid) and algorithm. The private keys never leave the server.
Sign and verify across a rotation
Sign a token, make a new key the signing key, then retire the old one. Each token names the key that signed it by kid, so old tokens keep verifying until their key is retired.
Do: Serve a JWKS, cache it with the key's
kid, and overlap old and new keys across a full token lifetime when rotating.Avoid: Hard-coding one key, rotating without an overlap window, or trusting a
jkuURL from inside the token.
Read more:RFC 7517: JWK & JWKS(opens in a new tab)RFC 7518: algorithms(opens in a new tab)