1. Algorithm confusion / "alg: none"
Older JWT libraries accept whatever alg the token's header claims. An attacker rewrites the header to {"alg": "none"}, strips the signature, and many libraries verify the token as valid. A variation: a token signed with HS256 using the server's public key as the HMAC secret tricks libraries that auto-pick the algorithm from the header.
Fix: Configure the verifier to accept only the algorithms you actually use. Never call verify() without an explicit algorithm whitelist. Most modern libraries require this, but legacy code paths often don't.
2. Trusting the payload before verifying
It is shockingly common to see code that decodes a JWT to read claims like userId or role and then uses those values before verifying the signature. The decoder is trivial; the signature check is the only thing that proves the token came from your auth server.
Fix: Verify first, then use claims. If you need claims to pick a verification key (rotating keys), check the kid header against your key store rather than trusting payload fields.
3. Long-lived tokens
A JWT with a 30-day expiry is a 30-day all-access pass. Until that token expires, you cannot revoke it server-side without building infrastructure JWTs were designed to avoid.
Fix: Short access tokens (5–30 minutes) plus a longer-lived refresh token stored differently. Revocation hits the refresh path, not every request. If you must keep server-side revocation, store a JTI denylist and check it on every request — but be honest that you've reintroduced session state.
4. Weak HMAC secrets
An HS256 token signed with a 12-character secret is brute-forceable offline. Once captured, an attacker can forge any token they want.
Fix: HMAC secrets should be at least 256 bits of entropy (32 random bytes). For RS256/ES256, key length matters less — algorithm is doing the lifting. Rotate keys regularly and support kid so rotation doesn't require a full deploy.
5. Leaking tokens via storage
Storing JWTs in localStorage exposes them to any XSS — every script that runs in your page can read them. Cookies are not magic protection either, but HttpOnly + Secure + SameSite=Strict cookies are much harder to steal.
Fix: Default to HttpOnly cookies for browser apps unless you have a specific reason not to. For SPAs, this means accepting that you need a session-cookie style architecture and being explicit about CSRF protection.
6. Missing or wrong audience/issuer checks
The iss and aud claims exist so a token issued for service A can't be replayed at service B. Many services check the signature and expiry but never verify aud. A leaked token from one service then opens the door to others sharing the same key.
Fix: Always pin aud to the specific resource server. If you use the same JWT for multiple services, give each service its own expected aud and require it.
7. Clock skew and replay
Servers with different clocks reject tokens that should be valid (or accept tokens that should be expired). Less obvious: a stolen token can be replayed until exp — there is no built-in single-use protection.
Fix: Allow modest clock skew (≤60 seconds) on exp and nbf. For sensitive operations, add a server-side nonce or jti tracking.
8. Confidential data in the payload
JWTs are signed, not encrypted (by default). The payload is plain Base64URL — anyone who sees the token sees the claims. Putting PII, PHI, or sensitive flags in claims means leaking those if the token is ever logged or intercepted.
Fix: Keep payloads to opaque identifiers (sub, scope). For confidentiality, use JWE (encrypted JWT) — but most apps don't need this; they just need to not put secrets in JWS payloads.
9. Confused deputy with kid
If kid is used to look up a key from a database or file, and the lookup is not strictly validated, an attacker can set kid to a path like ../../public.pem or to a SQL fragment.
Fix: Treat kid as untrusted input. Use it only as a key into a curated map of valid keys; never as a filesystem path or SQL parameter.
Quick audit checklist
- Verify before reading claims. Always.
- Whitelist algorithms; never trust the header's
algalone. - HMAC secret ≥ 256 bits of entropy.
- Access tokens ≤ 30 min; longer = refresh token.
- Check
issandaudon every verification. - HttpOnly cookies by default; localStorage only with eyes open.
- No PII in claims.
- kid maps to a validated key list, not a path.