Security

JWT Security Best Practices: The Bugs That Keep Showing Up

JWTs are fine until they aren't. Most JWT vulnerabilities come from the same five mistakes — algorithm confusion, missing verification, leaky storage, sloppy expiration, and silent revocation. Here's how to avoid them.

12 min read

JWTs are deceptively easy to use and even easier to misuse. Most production JWT bugs aren't bleeding-edge cryptography failures — they're predictable mistakes any team can avoid by being deliberate about a handful of design choices.

1. Pin the algorithm, never trust the header

The classic "alg: none" attack: an attacker forges a token with the header { "alg": "none" }and an empty signature. If your verification library trusts the header to decide which algorithm to use, it accepts the forgery.

// ❌ Wrong: lets the token decide its own verification jwt.verify(token, secret); // depending on lib, may auto-detect alg // ✅ Right: pin the algorithm explicitly jwt.verify(token, secret, { algorithms: ['HS256'] });

The related "algorithm confusion" attack: a server expects RS256 (asymmetric, verified with the public key) but the library accepts HS256 (symmetric, verified with whatever you pass as the secret). An attacker submits an HS256 token signed with the public key — the server uses the public key as if it were the HMAC secret, and verification "succeeds."

2. Verify every claim that matters

Decoding tells you what the token claims. Verification proves the issuer signed it. Most libraries verify the signature for you but stop there. You still need to actively check:

  • iss (issuer) — match against your expected list.
  • aud (audience) — token meant for your service, not someone else's.
  • exp (expiration) — in the future, with sane clock skew (60s typical).
  • nbf (not-before) — if present, currently after this time.
  • iat (issued-at) — sanity check; reject far-future tokens.

Many libraries default to checking exp/nbf but not iss/aud — explicitly add them. The damage from skipping aud is real: a token issued by your auth server for service A can be replayed against service B if both share the same key and don't check audience.

3. Storage on the client side

The eternal debate: localStorage vs cookies. The actual security comparison:

  • localStorage: any XSS in your app reads it. No CSRF risk. Survives across tabs and reloads.
  • HTTP-only cookie: JS cannot read it (XSS-resistant). Sent automatically with every request (CSRF risk unless mitigated). Requires SameSite=Strict or Lax, plus Secure in production.
  • Memory only (variable in app state): safest from XSS exfiltration but lost on reload. Common pattern for short-lived access tokens.

The current best-practice combo

Short-lived access token (5–15 min) in memory, long-lived refresh token in HTTP-only Secure SameSite=Strict cookie. The refresh endpoint exchanges a fresh access token. Compromised XSS can't exfiltrate the refresh token; lost access tokens self-expire fast.

4. Expiration discipline

Long-lived JWTs are a liability. Once issued, a JWT is valid until expiration regardless of what happens to the user, subscription, or session. If the token lives 24 hours, a stolen token is good for 24 hours of API access.

Modern guidance:

  • Access tokens: 5–15 minutes. Short enough that theft is limited; long enough to avoid constant refresh.
  • Refresh tokens: hours to days, depending on app risk. Rotated on each use (single-use refresh tokens).
  • Re-auth required after some maximum (24 hours typical for banking, 30 days for low-risk consumer apps).

5. Revocation requires a list

JWTs are designed to be self-contained — the server doesn't need to look anything up to verify them. That's a feature for performance and a problem for revocation. If a user logs out or you compromise a token, you cannot un-issue what's already issued.

Three approaches:

  1. Short expiration + refresh rotation: the de facto standard. Compromise window is one access token lifetime.
  2. Token denylist: maintain a fast lookup (Redis) of revoked token IDs (jti claim). Check on every verification. Reasonable scale to a few million entries.
  3. Per-user version number: include a version in the JWT. Bump it on logout / password change to invalidate every existing token for that user. Requires looking up the version on each request — back to a session-style design.

6. Secrets management

For HMAC tokens (HS256/HS384/HS512), the secret must be:

  • At least as long as the hash output (32+ bytes for HS256).
  • Cryptographically random (no human-typed phrases).
  • Stored in environment variables / secret manager, never committed.
  • Rotated periodically with a key ID (kid) in the header for graceful transitions.

For RS256/ES256, the private key signs and the public key verifies. Distribute the public key via a JWKS endpoint so verifiers can fetch it dynamically. Rotate by serving multiple keys during a transition window.

7. Don't put sensitive data in the payload

JWT payloads are Base64URL-encoded, not encrypted. Anyone with the token can read the contents. Never include:

  • Passwords or password hashes.
  • Social security numbers, payment data, health records.
  • Full user profile data — claims should be minimal IDs, not records.

If you genuinely need to encrypt JWT contents, use JWE (JSON Web Encryption) instead of JWS. Complexity goes up significantly; usually better to design around the need.

When NOT to use JWTs

For first-party web apps with a single backend, traditional session cookies are simpler, more secure by default, and easier to revoke. JWTs shine for: stateless microservices, third-party API access, mobile + web sharing the same backend, or any scenario where the verifier and issuer are different services. Don't reach for JWT because it's trendy.

Key Takeaways

  • Always pin the algorithm in verify() — don't let the token's header decide.
  • Verify iss, aud, exp, nbf — not just the signature.
  • Best storage: short-lived access token in memory, refresh token in HTTP-only Secure SameSite cookie.
  • Short access token TTL (5–15 min) is the simplest revocation strategy.
  • JWT payload is encoded, not encrypted — never put PII or secrets in the claims.