JWT authentication has more failure modes than most developers realise. Correct signature verification is necessary but far from sufficient. This checklist is what I run through before every production JWT deployment.
1. Secret Is Generated With a CSPRNG
Not a password. Not a UUID. Not a timestamp. A cryptographically secure pseudorandom number generator output.
In Node.js: crypto.randomBytes(32).toString('hex')
In Python: secrets.token_hex(32)
In the browser: jwtsecretgenerator.com/tools/jwt-secret-generator
A 256-bit CSPRNG secret takes 10^59 years to brute force at current GPU speeds.
2. Algorithm Is Explicitly Specified in verify()
// Wrong
jwt.verify(token, secret);
// Right
jwt.verify(token, secret, { algorithms: ['HS256'] });
3. exp Claim Is Present and Validated
Short-lived tokens (15 minutes) limit the damage from leaks. Verify your library is actually checking exp — some require explicit configuration.
4. iss and aud Claims Are Validated
Validates the token was issued by your service and intended for your API. Prevents token reuse across services.
5. Tokens Are in httpOnly Cookies, Not localStorage
localStorage is readable by any script on the page. httpOnly cookies are invisible to JavaScript.
6. HTTPS Is Enforced
JWT in a query parameter over HTTP is visible in every proxy, CDN, and server log on the path. Use the Authorization: Bearer header over HTTPS only.
7. Refresh Tokens Are Server-Side Revocable
Short access tokens + server-side refresh tokens = the ability to end sessions immediately. Long-lived access tokens without refresh logic cannot be revoked.
8. The jti Claim Is Used If You Need Immediate Revocation
Store revoked jti values in Redis with TTL matching token expiry. Check on every request. Adds one Redis lookup per request — worth it for high-security endpoints.
9. Different Secrets for Each Environment
Dev secret leaks should not compromise production. Keep them separate.
10. Secret Is Not in Source Code or Version Control
Check: git log -S "JWT_SECRET" -- . — if any results appear, rotate immediately.
11. Error Messages Do Not Reveal Why Verification Failed
// Bad — tells attacker whether to try a different token or different secret
return res.status(401).json({ error: 'Token expired' });
// Good
return res.status(401).json({ error: 'Unauthorized' });
12. Payload Does Not Contain Sensitive Data
JWT payload is base64, not encrypted. Decode any JWT in 3 seconds at jwt.io. Never put passwords, full PII, or financial data in a JWT payload.
The full version of this checklist with code examples for each point is at jwtsecretgenerator.com/blog/jwt-security-checklist-2026.
Top comments (1)
Solid list. Two things I'd add that sit inside items you already have. On #2 — "specify allowed algorithms" isn't quite enough if the allowlist mixes classes. The moment {RS256, HS256} are both accepted, you've re-opened the classic confusion attack: the attacker flips the header to HS256 and signs the token with your RSA public key as the HMAC secret. Public key is, well, public. Pin to a single algorithm the verifier enforces, and never let the token's own header choose.
The other one is a tension between #3 and #8 rather than a missing item. A 15-minute exp plus a Redis jti denylist for "immediate revocation" means you're doing a datastore lookup on every request — which is exactly the statefulness JWTs were picked to avoid. That's fine, but then the access token being stateless isn't buying you anything; you're paying the round-trip anyway. The honest fork is pick one: keep it truly stateless and accept up-to-15-min staleness on revoke (revoke only at the refresh boundary), or go stateful on purpose. Doing a per-request lookup and keeping short-lived tokens is the worst of both — you pay the lookup and still carry the blind window.