X Xerobit

JWT Security Checklist for 2026

Twelve checks every JWT implementation should pass before shipping. The actual checklist used by security teams, with the failure mode each prevents.

Mian Ali Khalid · · 11 min read
Use the tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Open JWT Decoder →

If you’re shipping a JWT-based auth system in 2026, you’re inheriting twenty years of accumulated mistakes — from alg: none attacks to kid SQL injection to refresh-token misbinding. Every one is preventable. Here’s the checklist I run before any JWT system goes live.

For the why-it-matters background, see Decoding a JWT Is Not the Same as Verifying It. This post is the operational checklist.

1. Algorithm allowlist (not algorithm blocklist)

✅ Define exactly which algorithms your verifier accepts. Reject everything else.

// ✅ Correct — explicit allowlist
jwt.verify(token, key, { algorithms: ['RS256'] });

// 🚫 Wrong — accepts whatever the token claims
jwt.verify(token, key);

Failure mode: an attacker submits a JWT with alg: HS256 while your server expects RS256. If your verifier accepts both, the attacker can sign with your public key (which is, by definition, public) and your verifier checks it as HMAC. Trivial bypass.

The fix: every issuer pairs with exactly one algorithm. Hardcode it.

2. Reject alg: none

✅ Verify your library rejects the special “no signature” algorithm. Test it explicitly.

// Test — should throw
const noneToken = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.";
jwt.verify(noneToken, key);  // expect: InvalidAlgorithmError

Failure mode: the original JWT spec allowed alg: none. Some libraries still accept it by default. An attacker crafts an unsigned token claiming admin role; the verifier accepts it.

Modern libraries (jsonwebtoken 8+, jose) reject alg: none by default. Audit yours. If you’re using a library that defaults-allow it, replace the library.

3. Validate iss (issuer)

✅ Verify the token’s iss claim matches the expected issuer URL/ID.

jwt.verify(token, key, {
  algorithms: ['RS256'],
  issuer: 'https://auth.yourcompany.com',  // exact match required
});

Failure mode: without issuer validation, a token from a different (potentially malicious) issuer signed with the right algorithm could pass. Especially relevant for multi-tenant systems where multiple issuers are configured.

4. Validate aud (audience)

✅ Verify aud matches your service. Reject tokens issued for other audiences.

jwt.verify(token, key, {
  algorithms: ['RS256'],
  audience: 'api.yourcompany.com',
});

Failure mode: an attacker steals a token issued for one service in your ecosystem (say, a billing API) and replays it to another (your admin API). If you don’t check aud, both accept the token.

5. Enforce exp (expiration)

✅ Reject tokens past their expiration. Don’t let “I’ll check this later” turn into “we never check this.”

Most libraries enforce exp by default but audit yours. Common config:

jwt.verify(token, key, {
  algorithms: ['RS256'],
  ignoreExpiration: false,    // explicit
  clockTolerance: 30,         // 30 sec clock skew allowed
});

Failure mode: a token that should have expired remains valid forever. Refresh-token rotation breaks. Stolen tokens have unbounded lifetime.

Set realistic expirations:

  • Access tokens: 5–15 minutes. Short-lived because they go on the wire.
  • Refresh tokens: hours to days. Stored more carefully (HTTP-only cookies, encrypted at rest).
  • Long-running service tokens: can be longer, but rotate them.

6. Enforce nbf (not-before) when present

✅ Reject tokens whose nbf is in the future.

This is rare in practice but matters for tokens issued ahead of time (e.g., scheduled credentials, deferred-activation flows). Most libraries handle it; verify yours doesn’t ignore nbf.

7. Strict kid (key ID) handling

✅ Use kid to look up the verification key from a trusted set, never to fetch from arbitrary URLs.

// ✅ Correct — kid maps to a fixed JWKS endpoint
const keys = await fetchKeysFromTrustedJwks();
const key = keys[token.header.kid];
jwt.verify(token, key, { algorithms: ['RS256'] });

// 🚫 Wrong — token says where the key lives
const keyUrl = token.header.jku;  // attacker-controlled
const key = await fetch(keyUrl).then(r => r.text());
jwt.verify(token, key, { algorithms: ['RS256'] });

Failure modes:

  • jku injection: if your verifier honors the token’s claimed jku URL, an attacker points it at their own JWKS hosting their public key and signs with the matching private key.
  • x5u injection: same idea with X.509 cert chains.
  • kid SQL injection: if kid is used in a database query without parameterization, classic SQL injection.

The fix: kid is only a lookup index into a hardcoded or pinned JWKS endpoint you control.

8. Use the right algorithm for the threat model

✅ Pick HS* (HMAC) or RS*/ES* (asymmetric) deliberately, not by accident.

AlgorithmWhen to use
HS256Single service issues and verifies. Shared secret never leaves your infrastructure. Symmetric. Simpler.
RS256Multiple services need to verify but only one issues. Public key for verification, private for signing.
ES256Like RS256 but with elliptic curves. Smaller signatures (64 bytes vs 256 for RS256). Use for new systems.
EdDSA / Ed25519Newer, faster, simpler. Increasing support.

Anti-patterns:

  • Using HS* across multiple services means sharing the secret with each one. One compromised service compromises all.
  • Using RS* when you only have one service is unnecessary complexity.

9. Short-lived access + rotating refresh

✅ Access tokens expire in minutes. Refresh tokens rotate on each use.

The pattern:

1. User authenticates → server issues access (15 min) + refresh (7 days)
2. Client uses access token until 401 expired
3. Client sends refresh token → server issues NEW access + NEW refresh
4. Server invalidates the OLD refresh token immediately (one-time use)
5. If a refresh token is used twice, ALL tokens for that user are revoked
   (signals that one was stolen)

The “rotation + reuse-detection” pattern catches stolen refresh tokens fast. If a stolen token is used and then the legitimate user’s client tries to refresh, the second use is detected → forced logout → user is alerted.

Failure mode without rotation: a stolen refresh token is good until expiration (often weeks). Stolen access tokens are good until brief expiry.

10. Storage: HTTP-only cookies, not localStorage

✅ Store tokens in HTTP-only, Secure, SameSite=Strict cookies.

Don’t:

  • Store JWTs in localStorage — XSS-readable, persists indefinitely.
  • Store JWTs in sessionStorage — XSS-readable, survives tab navigation.
  • Pass JWTs in URLs — leaks via referrer headers, server logs, browser history.

Do:

  • HTTP-only cookies for the JWT. JavaScript can’t access them, so XSS can’t exfiltrate them.
  • Secure flag (HTTPS-only).
  • SameSite=Strict or Lax (CSRF protection).
  • For SPAs that need to read tokens, use a short-lived in-memory token kept in a closure, refreshed via cookie-bound refresh flow. Never persist to storage.

11. Logout invalidates server-side state

✅ Logout marks the refresh token as revoked on the server. Don’t rely on client-side state.

JWTs are stateless by design — that’s their selling point — but stateless means can’t be revoked unless you add explicit state. For real-world auth:

  • Maintain a server-side “revoked refresh tokens” list (DB or Redis with TTL = refresh expiry).
  • On logout, add the token’s jti to the list.
  • On every refresh, check the list before issuing new tokens.
  • Access tokens are short-lived enough that pure-stateless verification is OK.

This is hybrid: stateless access tokens, stateful refresh tokens. Best of both worlds.

12. Don’t trust claims for authorization decisions without server-side checks

✅ The server checks current state, not just JWT claims, for high-stakes operations.

Example: a token issued at 10:00 says role: admin. At 10:30, the user’s role was downgraded to user. The token still says admin and is technically valid — but the database says “user”. What should the API do?

The right answer: high-stakes operations re-check the database. The token’s role claim is a hint, not a final answer.

For low-stakes ops (reading a feed, updating a profile), trusting the token is fine. For elevation-of-privilege ops (changing org permissions, deleting data, payment), check the live state.

Bonus: implementation checklist

Beyond the protocol-level checks, audit your code for:

  • No JWT secret in client-side code (obviously)
  • No JWT secret in version control (use env vars + secret manager)
  • Refresh tokens are rotated on use AND tracked server-side
  • CORS doesn’t allow arbitrary origins to send credentials
  • All JWT-bearing requests are HTTPS-only
  • Logging redacts tokens (logging a JWT is logging credentials)
  • CSRF tokens are separate from JWTs (defense in depth)
  • Error messages don’t leak which check failed (fail with generic 401)

Pre-deployment audit (one-page checklist)

Before any JWT system ships:

  1. ✅ Verifier has explicit algorithms allowlist
  2. alg: none rejected (test it)
  3. iss validated against expected issuer
  4. aud validated against this service’s identifier
  5. exp enforced (test with expired token)
  6. nbf enforced when present
  7. kid only used to look up keys from trusted JWKS
  8. ✅ Algorithm matches threat model (HS vs RS)
  9. ✅ Access tokens expire ≤15 min; refresh tokens rotate
  10. ✅ Tokens in HTTP-only Secure SameSite cookies
  11. ✅ Logout invalidates server-side
  12. ✅ High-stakes ops re-check live state

If any of these are unchecked, you have a known unfixed risk. Document the exception or fix before shipping.

Tools

  • Verify behavior: spin up a test that submits known-bad tokens (alg:none, expired, wrong-aud, wrong-iss) and assert they’re rejected.
  • Decode and inspect: JWT Decoder — for debugging your own tokens. Never paste production tokens into third-party tools.
  • Library audit: check your JWT library’s CVE history. jsonwebtoken, jose, pyjwt are well-maintained. Older or smaller libraries may have unpatched issues.

Bottom line

Most JWT failures aren’t because the cryptography is weak — it’s strong. They’re because the verifier didn’t enforce one of the dozen checks above. Treat verification as the security boundary, not as a formality. And remember: a decoded JWT proves nothing. Always verify, always with strict options, always with the right algorithm.

Further reading


Related posts

Related tool

JWT Decoder

Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.

Written by Mian Ali Khalid. Part of the Encoding & Crypto pillar.