JWT Authentication Mistakes That Keep Appearing in Production
A tour of the JWT pitfalls that still ship to production in 2026 — algorithm confusion, weak secrets, missing expiry, and the subtle ways a library can fail open.
JWTs have been around long enough that the common mistakes have been documented, blog-posted, and still keep showing up in production code. This isn’t because developers are careless — it’s because the failure modes are genuinely subtle, the libraries handle defaults inconsistently, and the JWT spec itself leaves some decisions to the implementer that probably should not have been.
This is a walk-through of the mistakes that I (and several friends doing security reviews) have watched ship in 2024, 2025, and so far in 2026.
A quick refresher
A JWT is three Base64URL-encoded sections separated by dots:
<header>.<payload>.<signature>
The header declares the signing algorithm (alg), the payload contains claims (sub, exp, iat, and any custom fields), and the signature is produced by signing the <header>.<payload> string with either a symmetric key (HMAC-SHA256, abbreviated HS256) or a private key (RSA or ECDSA).
You can decode the payload without a key — it’s Base64, not encrypted. Paste a token into our JWT Decoder to see what’s inside. Validating it is a different question, and it’s where everything goes wrong.
Mistake 1: accepting alg: none
The JWT spec allows "alg": "none" to indicate an unsigned token. Several widely-used libraries, historically, would accept such a token as valid if you called their verify function without being explicit about which algorithms you expected.
The attack: the attacker takes a real token, decodes it, changes the payload to {"sub": "admin"}, re-encodes it with "alg": "none", empties the signature section, and submits it. A permissive verifier accepts it because the algorithm field says “don’t bother checking.”
This is fixed in modern libraries, but not universally. Some libraries still accept none if you don’t pass an explicit algorithms list. Always configure your verifier with the exact algorithm you issued with, not “any algorithm the token claims to use.”
// Bad: trusts whatever algorithm the attacker writes into the header
jwt.verify(token, secret);
// Good: fails closed on any other algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
Mistake 2: algorithm confusion (HS256 vs. RS256)
This one is more exotic but has been exploited in real vulnerabilities. The attack abuses libraries that pick the verification algorithm from the token’s header rather than from server configuration.
Scenario: your server signs with RS256 and publishes the RSA public key for clients to verify. An attacker crafts a token with "alg": "HS256" and signs it — using the public key as if it were an HMAC secret. The server, reading the header, loads “the key” (the public key, which is not a secret), passes it to its HMAC verifier, and the signature matches. The token validates.
The defense is the same as for alg: none: be explicit about which algorithm you accept, and don’t let the token’s header influence which verification path runs.
Mistake 3: weak HMAC secrets
HS256 is HMAC-SHA256. It’s only as strong as the secret key. If you pick a short or guessable secret, it’s brute-forceable.
The vulnerability I’ve seen three times across code reviews: a JWT_SECRET that was set at some point to changeme in a staging environment and copied to production. 9 characters of lowercase ASCII is under 45 bits of entropy. A reasonably-equipped attacker who captures a single valid token can crack that secret in minutes with hashcat -m 16500.
For HS256, the secret should be at least 256 bits of entropy — 32 random bytes. Generate it with our Hash Generator or with a CSPRNG:
openssl rand -base64 32
Treat the secret like a database password: in a secret manager, rotated on a defined schedule, never committed to git.
Mistake 4: no expiry, or a very long one
A JWT includes an exp claim (expiration time, Unix seconds). If you issue a token without exp, it’s valid forever. If something leaks it — a client-side logging bug, a browser extension, a misconfigured CDN — the attacker has permanent access until you rotate the secret (which invalidates every token, everywhere).
Set a short exp. How short depends on the use case:
- Access tokens for APIs: 15 minutes is a common choice
- Refresh tokens: longer (hours to weeks), but stored more carefully
- One-time actions (password reset, email verification): 15 minutes to an hour
If your server-side library has a “verify without expiry” option, never use it in production. That flag exists for testing.
Mistake 5: storing tokens in localStorage
A JWT in localStorage is readable by any JavaScript running on the page, which includes:
- Any third-party script you embed (analytics, A/B testing, chat widgets)
- Any XSS exploit that manages to execute on any page of your domain
- Any browser extension that has permission to run on that domain
Every one of those cases lets the token walk out the door. The token is a bearer credential — whoever has it can use it.
The alternative is an HTTP-only, Secure, SameSite=Strict cookie. It’s still vulnerable to CSRF, but CSRF is defended with a separate token or the double-submit cookie pattern. XSS exfiltration via JavaScript is structurally impossible against a cookie the JavaScript can’t read.
If you inherit a system that stores JWTs in localStorage and moving to cookies requires a full auth refactor, the immediate mitigation is a strict Content Security Policy that disallows inline scripts and external script sources you don’t control. CSP doesn’t make XSS safe — it raises the bar for exploitation.
Mistake 6: putting sensitive data in the payload
The JWT payload is Base64, not encrypted. Anyone who receives the token can decode it.
Things that shouldn’t be in a JWT:
- Internal user IDs that you don’t expose elsewhere
- PII beyond what the user already sees about themselves
- Roles that map to internal infrastructure (rather than user-facing entitlements)
- Anything that, if read by a third party who somehow gets the token, would embarrass you or harm the user
A JWT payload should be the minimum needed to route the request: subject, issuer, expiry, a handful of coarse-grained scopes. The detail lives in your database, loaded on each request by the user ID.
Mistake 7: not validating iss and aud
The iss (issuer) and aud (audience) claims are there for a reason. If you have multiple services that all use JWTs — say, an API and an admin console — and they use different signing keys, that’s good. If they share a key, validating aud is what prevents a token issued for the admin console from being accepted by the API.
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com/',
audience: 'https://api.example.com/',
});
Without these checks, a token that’s valid for any service in your fleet is valid for all of them.
Mistake 8: missing clock skew handling
When you validate exp, you’re comparing it to “now” on the verifier. If your issuer and verifier are on different machines with clocks that are out of sync, you’ll occasionally reject tokens that the issuer just minted.
Most libraries have a clockTolerance option (often 30 seconds by default). Don’t disable it. Do make sure your servers are running NTP.
A minimal checklist
Before a JWT-authenticated system ships:
- Explicit algorithm list on every
verifycall. Never rely on defaults. - Secret entropy ≥ 256 bits for HS256; RS/ES keys from a proper keypair generator.
- Short
expon every issued token. Never mint without one. - Storage in an HttpOnly cookie unless you have a concrete reason otherwise.
- Minimal payload. User ID, issuer, audience, expiry, scopes. Nothing else.
- Issuer and audience validation enforced on every verify.
- Rotation plan — how do you revoke a compromised token? How do you roll the signing key?
The rotation question is worth pausing on. JWTs are “stateless” in that a verifier doesn’t need to check a database for every request — but that same property means they can’t be revoked before they expire. Any revocation scheme (allowlist of valid tokens, denylist of revoked ones) involves state, which defeats part of the point. Short expiry + refresh tokens is the standard compromise: the short-lived access token can’t be revoked, but its window of validity is brief, and the longer-lived refresh token is a database-backed credential that can be.
For local debugging
When you need to inspect a token — check which claims are inside, when it expires, what algorithm was used — paste it into the JWT Decoder . It runs in the browser, so the token doesn’t get uploaded anywhere. For generating a test signature, the HMAC Generator handles the HS256 / HS384 / HS512 family. Anything more serious should happen in code, under test.
JWTs are not complicated, but they have a high ratio of “footguns per feature.” Knowing the common mistakes is most of what separates an auth system that works from one that’s waiting to be breached.
Tools mentioned in this article
- JWT Decoder — Decode and inspect JWT tokens — header, payload and claims.
- Hash Generator — Generate SHA-1, SHA-256, SHA-384 and SHA-512 hashes from text.
- HMAC Generator — Generate HMAC signatures with SHA-1, SHA-256, SHA-384 and SHA-512.