JWT mistakes I see in 1 out of 3 enterprise apps.
TL;DR — JWT is a fine pattern when you understand what it is. It's a signed identity claim with an expiry — that's the whole job. Most of what goes wrong is not JWT's fault: it's teams treating the token as a database, an authorisation store, or a session. Here are the seven mistakes I keep finding in security reviews, and the 30-minute checks that surface them.
What JWT is, and what it isn't
A JWT is three things glued by dots: a header, a payload, a signature. The signature proves the payload hasn't been tampered with since it was issued. The payload typically carries an identity claim (subject), an issuer, an audience, and an expiry.
That's the whole job. JWT is "identity, signed, with a clock". It is not a session — it has no server-side state. It is not a permission store — anything you put in the payload is visible to anyone who can read the token. It is not a way to fix auth — it's a way to express auth that has been correctly designed elsewhere.
Roughly one in three security reviews I run finds at least one of the seven mistakes below. Most apps have two or three. None of these are exotic — they're all in the OWASP JWT cheatsheet. They're just easy to miss when "the JWT library handles it" feels like enough.
The 7 mistakes
1. The token carries permissions
This is the cookie-bloat failure mode in a different uniform. The token grows with your authorisation model — eventually crossing header limits, leaking permission structure to anyone who reads the token, and creating a permission cache you can never invalidate without forcing every user to re-authenticate.
The right shape is identity in the token, permissions resolved server-side from the database. Covered in detail here.
30-second check: decode a production token (use jwt.io with a non-real account). If the payload contains a list of strings that look like permission names, you have this mistake.
2. No audience or issuer validation on verify
Every JWT has iss (who issued it) and aud (who it was issued for). Most libraries verify the signature by default, but skip iss and aud unless you tell them to. This means a token issued for one of your services will happily authenticate a request to a different one of your services. If your auth provider issues tokens for many tenants, an attacker who acquires a token for tenant A can replay it against tenant B.
30-second check: grep your codebase for the JWT verify call. If you don't see aud and iss being explicitly checked against the value you expect, this is failing open.
# Python — what to look for
jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="api.yourapp.com", # ← required
issuer="https://auth.yourapp.com" # ← required
)
3. Algorithm confusion — "alg: none" or HS256/RS256 swap
Two well-known attacks. The first: a JWT with "alg": "none" in the header has an empty signature. Older libraries accept it. The second: a server expects RS256 (asymmetric), but accepts HS256 if the attacker submits one — and uses the public key as the HMAC secret. Either lets an attacker forge any token they want.
30-second check: in your verify call, make sure the allowed-algorithms list is explicit and short. algorithms=["RS256"] — single value, hardcoded. Never derive the algorithm from the token's own header.
4. No expiry, or expiry too long
I've seen JWTs with no exp claim. I've seen JWTs with an exp 90 days out. Neither is acceptable for an access token. Without expiry, a token leaked once is a token valid forever. With a long expiry, you've removed the only mechanism JWT has to invalidate itself.
The right shape is short-lived access tokens (5–15 minutes) plus a separate refresh token that's revocable server-side. Yes, that adds the database hit you were trying to avoid by using JWT. Welcome to security.
30-second check: decode a token. Read the exp claim. If the difference between exp and iat is more than 24 hours and there's no refresh-token flow, you have a problem.
5. No revocation path
"User has been compromised, please log them out everywhere." A pure-JWT system can't do that — the token is valid until it expires regardless of what your database says. Any real-world auth system needs at least one of:
- A short access-token lifetime + revocable refresh tokens (the standard answer)
- A token-version claim (
tv), incremented on a logout-everywhere event, checked against a per-user counter at verify time - A blocklist of revoked token ids (
jti), checked at verify time — small in practice if access tokens are short-lived
30-second check: ask the team "what does logout-everywhere do?" If the answer involves only deleting the token from the client or the database session, the server still trusts the leaked token until it expires.
6. Sensitive data in the payload
JWT payloads are base64-encoded, not encrypted. Anything in the payload is readable by anyone who has the token. I've seen email addresses, user phone numbers, internal user-ids, employer names, even trial-balance flags in JWT payloads — all of which leak the moment a browser extension, a server log, or a third-party tracking script gets hold of the token.
The rule: the payload should contain identity references and authorisation context, never personal data. Use the user id; resolve the email server-side when you need it.
30-second check: decode a production token. If it contains an email, a phone number, a name, or anything you wouldn't paste into a public Slack channel, this mistake is in.
7. The signing key in source control
The most embarrassing one. The HMAC secret or the RSA private key checked into the repository, committed once, possibly removed later but still in git history. Or hardcoded in the application config and copy-pasted across environments. Or — in two engagements I can name — the same dev/staging/production key, because rotating it was "too much work."
If your signing key is ever exposed, every token your service has ever issued or will issue is forged-able. The mitigation is environment separation, key rotation, and storing keys in a secrets manager — not the codebase.
30-second check:
git log -p -S 'JWT_SECRET' on the server repo. Anything? You have history to clean and a key to rotate.
The 30-minute review I run
If you have access to a test account and the source code, this sequence finds most JWT failure modes:
- Minute 0–5: log in, capture a token, decode it on jwt.io. Inspect the payload for permissions, sensitive data, and the expiry distance. Check the algorithm in the header.
- Minute 5–15: grep the codebase for the JWT verify call. Confirm explicit
aud,iss, and a short, hardcoded algorithm list. Confirm there's a verify path on every protected route, not just the login route. - Minute 15–25: ask the team to demonstrate logout-everywhere. Watch what they do. If the leaked token still works after the demo, that's the revocation gap.
- Minute 25–30: grep the repo for the signing key.
git log --all -p -S 'BEGIN RSA PRIVATE KEY'and similar. Anything in history is in the world.
The find rate, in my own experience: about a third of apps fail at least one of the seven. Most fail two or three. Nobody fails all seven, because the apps where the team understands JWT well don't fail any.
When you don't actually need JWT
JWT became a default partly because it sounds modern and partly because cookies got a bad name in the early 2010s. Both forces have outlived their reasons. If your app is a single web frontend talking to a single backend, both on the same domain, with no third-party integrations and no mobile app — a server-side session in a cookie is simpler, more revocable, and harder to misuse. The "JWT or cookie" debate is mostly a "do you need cross-domain or mobile?" question in disguise.
Where JWT genuinely earns its place:
- You have a mobile app or a third-party integration that can't accept your domain's cookies
- You have multiple backend services that need to validate identity without consulting a central session store on every request
- You're issuing tokens to partners — and you want the partner to be able to verify the signature without a callback to you
Outside those, the burden of JWT (revocation gymnastics, signing-key management, the seven mistakes above) outweighs its benefits. Pick the simpler tool when you can.
Want me to run the 30-minute review against your platform?