5 JWT implementation pitfalls still hitting production in 2026
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
algfrom the header and pass it dynamically toverify(token, key, algorithms=[alg]).”alg=nonearrives andalgorithms=['none']accepts it. - Library default options that allow
nonewhen noalgorithmslist is provided.
Fix
- Always pin allowed algorithms to a constant:
algorithms=["RS256"]. Whitelist, never derive from header. - Never hand the header’s
algto 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/passwdas 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
kidis a lookup into a whitelist (Map<string, Key>).- Never concatenate
kidinto a path or SQL string. - An unknown
kidis 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 unrestrictednbfallows future-dated tokens to be used now.iat(issued at): a token with a very oldiatcould be a replayed stolen token.
Fix
- Require
exp. Reject tokens without it. - Most libraries have
requireExpiration: trueor equivalent. Turn it on. - Validate
nbfandiattoo. - 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
expat issuance time (30 minutes is a common ceiling). Reject tokens whoseexpexceeds 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:
- Are
algorithmspinned to a constant? - Is
noneimpossible to accept? - Are HS256 and RS256 keys held in separate, type-specific containers?
- Is
kidresolved through a whitelist? - Is
exprequired (not just checked when present)? - Are
nbf,iat,audchecked too? - 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.