JWT Security: 7 Mistakes That Leave Your API Wide Open
I've reviewed a lot of production APIs over the years, and JWTs show up everywhere — auth services, microservices, mobile backends, internal tooling. They're elegant when implemented correctly. When they're not, they're a skeleton key that hands attackers your entire system.
What's frustrating is that the same mistakes keep appearing. Not because developers are careless, but because JWT libraries often make the dangerous path just as easy as the safe one, and the errors are silent — no stack trace, no warning, just a wide-open door.
Here are seven JWT pitfalls I still see in real applications, shipped to production, right now.
1. Trusting the alg Header From the Client
This one is old, well-documented, and still happens. It's the alg:none attack, and it's exactly what it sounds like.
A JWT has three parts: header, payload, signature. The header contains the algorithm — HS256, RS256, whatever. Some libraries, when verifying a token, look at the token's own header to decide which algorithm to use. So an attacker strips the signature, changes the alg field to "none", and submits:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.
No signature at all. And some libraries just... accept it.
The fix is straightforward: explicitly specify the allowed algorithms on your server side and never let client-supplied headers influence that decision. In Python with PyJWT:
jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
In Node with jsonwebtoken:
jwt.verify(token, SECRET_KEY, { algorithms: ['HS256'] })
Pass the allowlist. Always.
2. Weak or Guessable Secrets
If you're using HMAC-based JWT (HS256, HS384, HS512), the entire security model rests on one thing: the secret being unguessable. You'd think "secret123" wouldn't make it to production in 2024. You'd be wrong.
I've seen default secrets from tutorial code shipped verbatim. I've seen secrets like myapp_jwt_secret pulled straight from a README that was never updated. JWT secrets aren't passwords — they need to be high-entropy random values, minimum 32 bytes for HS256, ideally 64.
Generate one properly:
# bash
openssl rand -hex 64
Or in Node:
require('crypto').randomBytes(64).toString('hex')
And for anything beyond a small internal service, consider switching to RS256 (asymmetric). Your signing key stays private on the auth server; consumers only need the public key to verify. Leaked public keys don't let anyone forge tokens.
3. No Expiry — Or Expiry That's Never Checked
Setting exp in your token is step one. Step two — actually verifying it — is where things fall apart more often than you'd expect.
Some homebuilt JWT decoders just base64-decode the payload and read the claims without validating the signature or the expiry. Some libraries verify expiry only if you explicitly opt in. The result: tokens issued three years ago still work fine against your API.
Test this against your own endpoints. Grab a token, wait for it to expire (or manually backdate the exp in a test), and hit your API. If you get a 200, you have a problem.
Reasonable expiry windows depend on context: 15 minutes for sensitive operations, up to an hour for general access tokens, paired with longer-lived refresh tokens. Don't set exp to 2099 "just for testing" and forget to change it.
4. RS256/HS256 Confusion Attack
This one is subtle and genuinely clever. If your server supports both RS256 and HS256, an attacker can exploit the switch.
In RS256, you sign with a private key and verify with the corresponding public key. That public key is often published — JWKS endpoints exist precisely to share it. Now: what if an attacker takes your public key, uses it as the HMAC secret, signs a token with HS256, and submits it?
If your verification code dynamically selects the algorithm from the token header and uses the public key as the "secret" for HMAC verification, the forged token passes. The library is using the right key — it just doesn't realize the algorithm swap means that key is no longer a secret.
Mitigation: restrict to one algorithm per key type, validate the algorithm before selecting verification logic, and never automatically accept whatever alg the client sent.
5. Storing Sensitive Data in the Payload
JWT payloads are not encrypted. They're base64url-encoded, which looks like gibberish but decodes in milliseconds:
echo "eyJzdWIiOiIxMjM0IiwicGFzc3dvcmQiOiJzZWNyZXQifQ" | base64 -d
{"sub":"1234","password":"secret"}
Yet I regularly see tokens containing email addresses, internal user IDs, role mappings, plan names, feature flags, and occasionally things much worse — API keys embedded in the payload, PII, or internal service URLs.
The payload is visible to anyone who intercepts the token or reads it from localStorage. Keep claims minimal: a subject ID, expiry, maybe a scope. Everything else should be looked up server-side from that ID. If you genuinely need to put sensitive data in a token, use JWE (JSON Web Encryption) — but most applications don't actually need this; they just need to stop overpacking the payload.
6. No Token Revocation Strategy
JWTs are stateless by design, and that's mostly a feature. But it creates a hard problem: what do you do when a user logs out, changes their password, or gets compromised? The token is still mathematically valid until it expires.
A lot of APIs just accept this and set short expiry windows to limit the blast radius. That works okay for low-stakes applications. For anything handling real money, personal data, or elevated privileges, you need more.
Common approaches:
- Blocklist by JTI: Add a
jti(JWT ID) claim — a unique identifier per token. On logout or revocation, store the JTI in Redis with a TTL matching the token expiry. Check it on every request. Fast, simple. - Short-lived access + refresh rotation: 15-minute access tokens, refresh tokens that get rotated on use. Revoke the refresh token; the access token expires naturally within minutes.
- Versioned user secrets: Include a per-user secret version in the token. Incrementing it on the server (stored in your DB) immediately invalidates all outstanding tokens for that user.
Pick the one that matches your threat model and actually implement it — "we'll add revocation later" has a way of never arriving.
7. Trusting Unvalidated Claims for Authorization
This is the quietest one. Everything else on this list is about the integrity of the token itself. This one is about what you do with it after verification.
You verify the signature, check the expiry, confirm the algorithm — all correct. Then you do this:
const role = decodedToken.role;
if (role === 'admin') {
// give full access
}
The question is: where did that role claim come from? If your auth service signed it and the signature is valid, this is fine. But if any part of your system allows users to influence what goes into their token — through profile updates, API parameters, or indirect logic — you may be trusting claims that an attacker influenced.
The principle is simple: treat the JWT as a verified assertion from your auth server, nothing more. Don't use claims as a substitute for checking permissions against your actual database. For any sensitive operation, cross-reference: does the user the token represents actually have this permission, according to your source of truth right now?
A token saying "admin: true" is a signed claim, not a ground truth. Especially if that claim was set when the user was an admin and they've since been demoted.
How to Actually Audit This
Don't just read the list — test against your own API. A quick checklist:
- Send a token with
"alg":"none"and no signature. Does your server reject it? - Send an expired token. Do you get a 401?
- Decode your token payload at jwt.io right now. What's in there that shouldn't be?
- Log out of your app. Can you replay the old token and still access protected endpoints?
- If you use RS256, try signing a token with your public key using HS256. Does it get accepted?
Most JWT libraries are solid if you configure them correctly. The vulnerabilities aren't usually in the library — they're in the integration. Default-permissive settings, missing algorithm restrictions, implicit trust in payload claims.
Five minutes of testing against your own endpoints will tell you more than any static audit. Run these checks before your next deploy. Fix what you find. Then make them part of your standard API review process.
The good news is that every mistake on this list has a clear, implementable fix. None of this requires exotic infrastructure or a security team. It just requires knowing what to look for — and now you do.