JWT auth lifecycle — stateless verify, refresh, and the edge cases

· updated

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

Open fullscreen ↗

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:

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.

PanelWhat it shows
FlowThe core path: ClientAuth 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.
TokenThe 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.
VerifyThe server-side verification pipeline, step by step: split on . → decode header → check alg against an allowlist → decode payload → recompute HMAC and constant-time compareexpnbfaudiss. The first failing check short-circuits to a 401. A big DB / session lookups = 0 callout is the statelessness proof.
TimelineToken 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.
ScalingWhy 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

Controls

Key / mouseAction
spaceRun / pause the scripted lifecycle
sStep one engine tick (50 ms of animation)
shift+sStep ×10 — jump to the next phase
rReset (clear tokens, rewind to /login, restore knob defaults)
hover anythingContext-aware explanation (HTML clouds on the toolbar / canvas tooltips on every panel region)
toolbar knobsHardened (reject alg:none / pin the algorithm) · Rotate (refresh rotation + reuse detection) · Denylist (stateful revocation) · Stateful (session-store contrast)

References

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.

← security