JWT auth lifecycle — stateless verify, refresh, and the edge cases
A single-file, in-browser visualizer of what actually happens when an app
authenticates you with JSON Web Tokens — from the password you type once,
through the signed token that rides every subsequent request, to the silent
refresh that keeps you logged in, and every way it can go wrong. The crypto is
real: the simulator implements SHA-256 and HMAC-SHA256 from scratch and signs
genuine HS256 tokens with the secret A secret string :). Tampering with a
claim really does break the recomputed signature — that is the whole point.
The default script walks the lifecycle and then every canonical edge case — the ones you only learn about after one of them bites you in production:
login → POST /login; password verified ONCE
issue → server signs access (15 min) + refresh (7 days)
store → access → memory, refresh → HttpOnly cookie
call → GET /api/me with Authorization: Bearer <access>
verify → recompute HMAC, check exp/nbf/aud/iss · 0 DB queries
use → 200; API reads sub/role straight from the payload
expgap → 15 min pass; the access token's exp is now in the past
denied → next call → 401 token_expired
refresh → POST /refresh → new access token (+ rotated refresh)
retry → same call, fresh token → 200; no re-login
tamper → edit role "user"→"admin"; signature no longer matches → 401
alg:none → header alg="none", signature dropped → hardened 401 / naive 200 ⚠
no-auth → no Authorization header → 401 missing_token
nbf → token not-valid-before 5 min from now → 401 (±30 s leeway)
wrong-aud → token minted for api://billing → 401 invalid_audience
leaked-secret → attacker signs their own admin token → it PASSES (200 ⚠)
refresh-reuse → replay a rotated refresh token → reuse detected → family revoked
logout → stateless token can't be un-issued → valid until exp (the gap)
scale → same token validates on 3 instances with only the shared secret
What this actually is (for someone who’s never touched auth)
When you log into a website, the server has to remember that this request, and the next thousand, come from someone who already proved they know the password. There are two ways to do that.
The old way is a session: the server makes up a random id, hands it to your
browser in a cookie, and writes a row in a database — session_abc123 → user 42.
Every request, the server looks that row up. Simple, but every server that
handles your traffic has to be able to reach that same session store, and the
store is asked a question on literally every request.
The JWT way turns it inside out. Instead of a meaningless random id, the server hands you a small, signed document that says, in plain text, “this is user 42, role user, valid until 3:15pm” — and signs it with a secret only the servers know. Now the server doesn’t have to remember anything: when your request arrives, it re-checks the signature and reads the document. If the signature is valid and the clock hasn’t run out, you’re in. No database lookup. That property — stateless verification — is the entire reason JWTs exist, and the Verify and Scaling views are built to make it tangible.
A JWT is three base64url-encoded chunks joined by dots — header.payload.signature:
- header —
{"alg":"HS256","typ":"JWT"}. Which algorithm signed it. - payload — the claims: who you are (
sub), who issued it (iss), who it’s for (aud), when it dies (exp), and any app data likerole. This is base64url, not encryption — anyone can read it. Never put a secret in here. - signature —
HMAC-SHA256(header.payload, secret). This is what makes the first two parts tamper-evident. Change one byte of the payload and the signature the server recomputes no longer matches the one attached.
The catch — and the reason half the edge cases below exist — is that statelessness cuts both ways. Because the server keeps no record of the token, it also can’t un-issue one. A stolen access token works until it expires; a “logout” can’t reach out and kill it. The two real defences are a short lifetime (so you mint a 15-minute access token and a long-lived refresh token to renew it silently) and, if you truly need instant revocation, a denylist — which quietly hands back the statelessness you came for. The simulator lets you toggle exactly that trade-off and watch the DB queries counter tick up from 0.
Views
All five render together on one screen — there are no tabs to switch between; hover any panel region for a context-aware explanation.
| Panel | What it shows |
|---|---|
| Flow | The core path: Client ↔ Auth Server (mints + signs) ↔ Resource API (verifies + serves), with animated request/response packets and a live HTTP-exchange panel — the actual method, headers (Authorization: Bearer …), body, and status for the current phase. Phase dots track progress through the whole lifecycle. |
| Token | The token taken apart, jwt.io-style: the three colour-coded base64url segments up top, the decoded JSON header and payload below with every claim annotated to RFC 7519 §4.1 and a live exp countdown, and the signature formula with the actual secret. |
| Verify | The server-side verification pipeline, step by step: split on . → decode header → check alg against an allowlist → decode payload → recompute HMAC and constant-time compare → exp → nbf → aud → iss. The first failing check short-circuits to a 401. A big DB / session lookups = 0 callout is the statelessness proof. |
| Timeline | Token lifetimes on a time axis (not to scale): the short 15-minute access token vs the 7-day refresh token, the /refresh + rotation event minting a new access token, and the revocation gap — the window in which a logged-out access token still works. |
| Scaling | Why JWT exists: the same token validates on any of N API instances behind a load balancer using only the shared secret — no sticky sessions, no shared store. Flip the Stateful knob and watch every instance suddenly depend on a single Redis box. |
What’s “precise” about it
- Real HMAC, not hand-waving. SHA-256 (FIPS 180-4) and HMAC-SHA256 (RFC 2104)
are implemented in the file — no Web Crypto, no library, so it runs from a bare
file://. The tokens are genuine HS256 JWTs (RFC 7519 / 7515 / 7518); the signature in the Token view verifies on jwt.io with the secret shown. The audit harness extracts a token from the running sim and re-verifies it with Node’scrypto.createHmacto prove the hand-rolled crypto is byte-for-byte correct, and that a one-character edit makes Node reject it too. - Real base64url. RFC 4648 §5 — URL-safe alphabet (
-_for+/), padding stripped. The segments you see are the actual encoded bytes. - The algorithm allowlist is the whole ballgame. A correct verifier pins the
algorithms it will accept and never trusts the
algfield in the token. Turn the Hardened knob off and the verifier naively honoursalg:"none"— reproducing the CVE-2015-9235 class of forgery where an attacker strips the signature and walks in as admin. - Registered-claim checks per RFC 7519 §4.1.
exp(§4.1.4),nbf(§4.1.5),aud(§4.1.3),iss(§4.1.1) are all validated, with a ±30 s leeway for clock skew — the same small tolerance real verifiers apply so two servers with slightly different clocks don’t reject each other’s fresh tokens. - Refresh-token rotation with reuse detection. Straight out of the OAuth 2.0
Security Best Current Practice (RFC 9700).
Each
/refreshissues a new refresh token and retires the old one; replaying a spent one is treated as theft and revokes the entire token family. This is the one place “stateless” auth keeps server state — and the sim is honest about it. - The revocation gap is shown, not hidden. Logout on a pure JWT can’t recall a live access token. The Timeline view draws the gap explicitly; the Denylist knob closes it — at the cost of a per-request lookup that the DB queries badge dutifully counts.
- Leaked-secret realism. If the signing key leaks, an attacker forges a fully
valid admin token and verification passes. The sim shows the 200 in a warning
colour: crypto can’t save you from a leaked key — short
expand key rotation limit the blast radius.
Controls
| Key / mouse | Action |
|---|---|
space | Run / pause the scripted lifecycle |
s | Step one engine tick (50 ms of animation) |
shift+s | Step ×10 — jump to the next phase |
r | Reset (clear tokens, rewind to /login, restore knob defaults) |
| hover anything | Context-aware explanation (HTML clouds on the toolbar / canvas tooltips on every panel region) |
| toolbar knobs | Hardened (reject alg:none / pin the algorithm) · Rotate (refresh rotation + reuse detection) · Denylist (stateful revocation) · Stateful (session-store contrast) |
References
- JWT / JWS / JWA: RFC 7519 (JSON Web Token), RFC 7515 (JSON Web Signature), RFC 7518 (algorithms, incl. HS256).
- Bearer scheme: RFC 6750 —
Authorization: Bearer. - base64url: RFC 4648 §5.
- HMAC + SHA-256: RFC 2104 and FIPS 180-4.
- Refresh rotation, reuse detection, sender-constraining: RFC 9700 — OAuth 2.0 Security Best Current Practice.
- The
alg:none/ algorithm-confusion family: Tim McLean’s 2015 write-up, “Critical vulnerabilities in JSON Web Token libraries,” and CVE-2015-9235. - Hands-on decoder: jwt.io — paste a token from the Token
view with the secret
A secret string :)and watch it verify.
How it works under the hood
The simulator is a single HTML file with inline CSS and JavaScript — no build, no
server, no toolchain, no external assets. A small IIFE holds the engine state
(the two tokens, the refresh-token family, an optional denylist, the scenario
phase and the simulated clock) and exposes send({ cmd }) / getState(), mirrored
on window.__SIM__ for headless audit. The visualizer is a requestAnimationFrame
loop wrapped in try/catch so a single draw bug can’t freeze the UI: each frame it
reads the engine state and draws all five panels — flow, token, verify,
timeline, scaling — onto a single 1180 × 700 logical canvas, rebuilding a
registry of hover regions so every meaningful part of every panel answers when
you point at it.
The crypto path is deliberately first-principles. signHS256() builds
base64url(header) + "." + base64url(payload), runs it through the in-file
hmacSha256(), and appends base64url(mac). verifyToken() does the reverse and
returns both a verdict and the per-step trace the Verify view renders — so the
pipeline you watch is the exact code path that decides 200 vs 401, not a cartoon
of it.