Why JWTs Are Still Getting Exploited
JSON Web Tokens have been the standard mechanism for stateless API authentication for over a decade. The specification is well-understood, the libraries are mature, and the vulnerabilities are thoroughly documented. And yet JWT implementation flaws continue to appear in bug bounty reports, pentests, and breach post-mortems with striking regularity.
The reason is that JWT’s flexibility — which is a genuine design feature — creates a surface area where subtle misconfigurations have high-severity consequences. The algorithm is specified by the token itself. The signature can be absent. The secret can be weak and guessable. Each of these properties, if a library handles them naively, enables authentication bypass.
This guide covers the four most common JWT vulnerability classes, with vulnerable and fixed code examples in Python, JavaScript, and Java.
Vulnerability 1: The none Algorithm Attack
The JWT specification allows "alg": "none" as a valid algorithm value, indicating an unsigned token. Some libraries, particularly older versions, honour this without checking whether the application actually intends to accept unsigned tokens.
Attack: Strip the signature from a valid JWT, change the alg header to "none", and present it. If the library accepts it, the attacker controls all token claims.
Vulnerable Python (PyJWT <2.0):
import jwt
# Attacker-crafted token with alg: none
token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9."
# Old PyJWT accepted this without error
decoded = jwt.decode(token, options={"verify_signature": False})
# decoded = {"sub": "admin", "role": "admin"}
Secure Python:
import jwt
def verify_token(token: str, secret: str) -> dict:
try:
# Explicitly specify allowed algorithms — NEVER pass algorithms=None
payload = jwt.decode(
token,
secret,
algorithms=["HS256"], # Whitelist only
options={"verify_exp": True}
)
return payload
except jwt.ExpiredSignatureError:
raise AuthError("Token expired")
except jwt.InvalidTokenError as e:
raise AuthError(f"Invalid token: {e}")
Never pass algorithms=None or allow the library to infer the algorithm from the token header.
Vulnerability 2: Algorithm Confusion (RS256 → HS256)
This is the most sophisticated and highest-impact JWT attack. It exploits libraries that accept both asymmetric (RS256) and symmetric (HS256) algorithms on the same endpoint.
The attack:
- The server uses RS256 — tokens are signed with the private key, verified with the public key
- The attacker obtains the public key (often published at a JWKS endpoint)
- The attacker crafts a token with
"alg": "HS256"and signs it with the public key as the HMAC secret - A naive library sees
alg: HS256, treats the verification key as the HMAC secret, and verifies the signature using the (known) public key — which matches the attacker’s signature - Authentication bypassed
Vulnerable Node.js pattern:
const jwt = require('jsonwebtoken');
// Dangerous: letting the library use whatever algorithm the token specifies
app.post('/verify', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
// VULNERABLE: publicKey used as secret when alg=HS256 in token header
const decoded = jwt.verify(token, publicKey); // No algorithms restriction
res.json({ user: decoded.sub });
});
Secure Node.js:
const jwt = require('jsonwebtoken');
const ALLOWED_ALGORITHMS = ['RS256']; // Never include HS256 if using RSA
app.post('/verify', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ALLOWED_ALGORITHMS, // Hard-coded algorithm whitelist
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
res.json({ user: decoded.sub });
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
});
The fix is always the same: hard-code the expected algorithm on the verification side. Never trust the alg header in the token.
Vulnerability 3: Weak HMAC Secrets
HS256-signed tokens are only as secure as the secret used to sign them. Common failures:
- Default secrets from documentation or tutorials (
secret,your-256-bit-secret, application name) - Short secrets (under 256 bits for HS256)
- Environment variable defaults not overridden in production
- Secrets committed to version control
Detection via offline cracking:
Tools like hashcat and jwt_tool can crack weak JWT secrets offline:
# jwt_tool brute force
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
# hashcat mode 16500 (JWT HS256/HS384/HS512)
hashcat -a 0 -m 16500 <token> /usr/share/wordlists/rockyou.txt
If a secret can be cracked offline, an attacker can forge arbitrary tokens for any user.
Secure secret generation:
import secrets
import base64
# Generate a cryptographically random 256-bit (32-byte) secret
secret = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
print(secret) # Store this in a secrets manager — not in code or .env files
// Node.js
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('base64url');
For production applications, store JWT secrets in AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or HashiCorp Vault — not in environment variables or configuration files.
Vulnerability 4: Missing Claim Validation
Even with a correctly verified signature, JWTs can be misused if claim validation is incomplete.
Common failures:
# VULNERABLE: signature verified but no claim validation
payload = jwt.decode(token, secret, algorithms=["HS256"])
# If token was issued for a different service, an attacker can replay it here
# SECURE: validate all relevant claims
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
options={
"verify_exp": True, # Token hasn't expired
"verify_nbf": True, # Token is valid now (not before)
"verify_iat": True, # Issued-at is reasonable
"verify_aud": True, # Token was issued for this audience
"verify_iss": True, # Token was issued by trusted issuer
},
audience="api.example.com",
issuer="https://auth.example.com",
)
Java (Spring Security / JJWT):
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
public Claims validateToken(String token) {
Key signingKey = Keys.hmacShaKeyFor(secretBytes); // 256+ bit secret
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.requireIssuer("https://auth.example.com")
.requireAudience("api.example.com")
// JJWT validates exp automatically when present
.build()
.parseClaimsJws(token) // Throws if invalid — never use parseClaimsJwt()
.getBody();
}
Note: use parseClaimsJws() (which validates signature) never parseClaimsJwt() (which does not).
Secure JWT Checklist
- Algorithm is hard-coded on the server side — never read from the token header
-
nonealgorithm is explicitly rejected - HMAC secrets are at least 256 bits, randomly generated, stored in a secrets manager
- RS256/ES256 public keys cannot be used as HS256 secrets (enforce algorithm type at the verification layer)
-
exp,iss,audclaims are validated on every request - Short token lifetimes (15 minutes for access tokens) with refresh token rotation
- JWTs are not stored in localStorage (prefer httpOnly cookies for browser-facing applications)
- Token revocation strategy exists (JTI blocklist, short expiry, or refresh token rotation)
Testing Your Implementation
jwt_tool covers all the attack classes above:
# Install
pip install jwt_tool
# Test all JWT attacks against a live endpoint
python3 jwt_tool.py <token> -t https://api.example.com/protected \
-rh "Authorization: Bearer *JWT*" \
-M at # All tests
The none algorithm, RS256→HS256 confusion, and weak secret attacks all have automated test modes in jwt_tool. Run these against your authentication endpoints as part of your security testing pipeline.