5 JWT implementation pitfalls still hitting production in 2026

5 min read

JWT is not a young spec (RFC 7519 is from 2015), and yet implementations still ship with broken validation logic. This article walks through five patterns that show up repeatedly in code review and security reports, with the attack and the fix for each.

1. Allowing alg=none

The original sin and the most fatal. JWT explicitly defines an ”unsigned” algorithm: alg: "none".

{
	"alg": "none",
	"typ": "JWT"
}.{
	"sub": "admin",
	"exp": 9999999999
}.

When a token arrives with empty signature bytes (the part after the second dot), some libraries say “alg is none, no verification needed” and let it through. This is the canonical 2015 CVE family (CVE-2015-9235 and friends), but it still recurs:

  • “Read alg from the header and pass it dynamically to verify(token, key, algorithms=[alg]).” alg=none arrives and algorithms=['none'] accepts it.
  • Library default options that allow none when no algorithms list is provided.

Fix

  • Always pin allowed algorithms to a constant: algorithms=["RS256"]. Whitelist, never derive from header.
  • Never hand the header’s alg to the verifier.
  • If the library has a “disable none” flag, turn it on.

2. Public-key-as-HMAC-secret confusion (HS256 ↔ RS256)

Against an RS256 (asymmetric) service, an attacker sends a token with alg rewritten to HS256:

Header:    {"alg": "HS256"}
Payload:   {"sub": "admin"}
Signature: HMAC-SHA256(public_key, "header.payload")

The server reads “HS256 → symmetric key” and helpfully uses the RSA public key as an HMAC secret. The public key is, by definition, public (often served at /.well-known/jwks.json), so the attacker can forge a valid HMAC and the signature checks out.

Fix

  • Separate keys by algorithm: HS256 keys are different objects from RS256 keys, used only with their algorithm.
  • And as in #1: don’t trust the header’s alg. Pin the algorithm list.

3. kid injection redirecting to attacker-controlled keys

kid (key ID) is a header field servers use to look up a key:

{
	"alg": "RS256",
	"kid": "my-key-1"
}

Naïve implementations use kid directly as a file path or SQL parameter. Putting ../ or SQL syntax in kid enables:

  • "kid": "../../etc/passwd" → server tries to read /etc/passwd as the key file, succeeds, hashes it as a key. Attacker who knows the file contents can sign a matching token.
  • "kid": "x' UNION SELECT 'attacker_key' --" → SQL injection returns an attacker-controlled key.

Fix

  • kid is a lookup into a whitelist (Map<string, Key>).
  • Never concatenate kid into a path or SQL string.
  • An unknown kid is an immediate reject — don’t go searching.

4. Missing or skipped exp (and forgetting nbf / iat)

exp is technically optional in the spec. Code like this fails open when the issuer omits it:

if (payload.exp && payload.exp < Date.now() / 1000) throw new Error('expired');

If payload.exp is undefined, the check is skipped — the token is valid forever.

Also commonly skipped:

  • nbf (not before): an unrestricted nbf allows future-dated tokens to be used now.
  • iat (issued at): a token with a very old iat could be a replayed stolen token.

Fix

  • Require exp. Reject tokens without it.
  • Most libraries have requireExpiration: true or equivalent. Turn it on.
  • Validate nbf and iat too.
  • Keep clock-skew leeway tight (a few seconds, ≤ 30s).

5. Key rotation and revocation oversight

The healthy pattern is short-lived JWTs (minutes to hours) with periodic signing-key rotation. Common gaps:

  • The JWKS is fetched once at startup and cached forever — new kids introduced after that fail to verify.
  • A leaked key removed from JWKS is still in attacker hands; tokens signed with it remain valid until exp.
  • Library or schema doesn’t model multi-key state; you can never rotate without downtime.

Fix

  • Re-fetch JWKS on a TTL (minutes to an hour).
  • Accept only currently-listed kids; aggressively prune retired keys.
  • Set a hard cap on exp at issuance time (30 minutes is a common ceiling). Reject tokens whose exp exceeds the cap, even if signed correctly.
  • Keep refresh tokens server-side and put them on a denylist when leaks are suspected.

Adjacent gotchas worth a mention

Outside the top five but adjacent enough to flag:

  • Not validating aud: a token from one service inside the same IdP can be replayed against another.
  • Oversized JWT: ALB and nginx default header limits (~8 KB) bite when you stuff too many claims.
  • PII in payload: JWT is base64url-encoded, not encrypted. Anyone with the token can read the claims.
  • Verifying signatures on the client: the client doesn’t have, and shouldn’t have, the verification key. Server-side only.

Review checklist

Run this list in code review every time:

  1. Are algorithms pinned to a constant?
  2. Is none impossible to accept?
  3. Are HS256 and RS256 keys held in separate, type-specific containers?
  4. Is kid resolved through a whitelist?
  5. Is exp required (not just checked when present)?
  6. Are nbf, iat, aud checked too?
  7. Is JWKS re-fetched on a defined TTL?

The JWT decoder lets you take a real token apart to inspect the header, payload, and signature — useful when you’re auditing whether alg and kid look correct.

Summary

Most JWT vulnerabilities trace to the spec’s flexibility. The three principles — don’t trust the header’s alg, validate all of exp/nbf/iat/aud, and isolate keys by algorithm — block the majority of the recurring failures. New implementations should run this checklist before going live; old ones rediscover the same CVE shapes embarrassingly often.