I keep encountering this JWT vulnerability in Python codebases, and it’s particularly concerning because it’s so easily overlooked. Developers implement what appears to be proper JWT authentication—they validate signatures, check expiration, handle all the edge cases. But there’s one subtle mistake that can completely undermine the entire security model.

The issue is trusting the JWT’s own header to determine how to verify it. This is similar to asking someone to specify which method you should use to verify their identity.

The Problem: Trusting Attacker-Controlled Input

JSON Web Tokens (JWTs) contain a header that specifies which algorithm was used to sign them. Here’s what a typical JWT header looks like:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-id-123"
}

Here’s the vulnerable pattern I’ve seen in Python apps:

import jwt

def validate_token_wrong(token):
    # DON'T DO THIS - Reading algorithm from token header
    unverified_header = jwt.get_unverified_header(token)
    algorithm = unverified_header.get('alg')  # ⚠️ DANGEROUS
    
    # Using attacker-controlled algorithm choice
    payload = jwt.decode(
        token,
        key=public_key,
        algorithms=[algorithm]  # ⚠️ TRUSTING THE ATTACKER
    )
    return payload

The problem? That alg field comes from the token itself—which means an attacker controls it entirely.

Attack Vector 1: The “none” Algorithm Bypass

This is the most straightforward attack vector. An attacker changes the algorithm in the JWT header to "none":

{
  "alg": "none",
  "typ": "JWT"
}

When PyJWT sees algorithms=["none"], it skips signature verification entirely. At this point, an attacker can forge any token they want:

# Attacker creates unsigned token
import base64
import json

header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "admin", "exp": 9999999999}

# No signature needed!
fake_token = (
    base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=') + '.' +
    base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=') + '.'
)

# This will validate successfully with the vulnerable code above

The Library Behavior That Makes This Worse

Here’s where it gets interesting—and more dangerous. Modern JWT libraries like PyJWT have inconsistent behavior when handling alg: "none" tokens with signature data.

Let’s test this with different token formats:

import jwt
import json
import base64

# Create malicious none token variants
header = {'alg': 'none', 'typ': 'JWT'}
payload = {'sub': 'admin', 'role': 'administrator'}

header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=')
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')

# Test different token formats
test_tokens = [
    f'{header_b64}.{payload_b64}.',  # Empty signature
    f'{header_b64}.{payload_b64}.fakesig',  # Random fake signature
    f'{header_b64}.{payload_b64}.eyJmYWtlIjoidHJ1ZSJ9'  # Valid base64 
]

Now test how PyJWT handles each format:

for token in test_tokens:
    try:
        # The vulnerable pattern from our earlier example
        unverified_header = jwt.get_unverified_header(token)
        if unverified_header.get('alg') == 'none':
            decoded = jwt.decode(token, key=None, algorithms=['none'], 
                              options={'verify_signature': False})
            print(f'⚠️  Vulnerable: {decoded}')
    except Exception as e:
        print(f'✓ Blocked: {e}')

Results with PyJWT 2.13.0:

  • Empty signature → ✅ Attack succeeds
  • Random fake signature → ❌ Blocked (base64 decode error)
  • Valid base64 signature → ✅ Attack succeeds

This inconsistency means attackers have multiple approaches:

  1. Clean attack: Use proper none format (header.payload.)
  2. Padded attack: Include valid base64 data as fake signature to avoid parsing errors

Both work with vulnerable validation code, but the second approach might bypass additional security tools that only test for the “obvious” none attack format.

Key Takeaway

The inconsistent library behavior means attackers have multiple ways to exploit the same vulnerability. This reinforces why trusting the token’s algorithm header is fundamentally flawed—you’re not just trusting the attacker’s choice, you’re also dealing with unpredictable library edge cases.

Attack Vector 2: Algorithm Substitution (RS256 → HS256)

The second attack is more sophisticated but equally dangerous. When an app uses RS256 (RSA signatures), an attacker can switch the algorithm to HS256 (HMAC) and use the app’s own public key as the HMAC secret. This works because of how cryptographic libraries handle different signature algorithms.

Here’s why this works:

# Your legitimate setup uses RSA
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFA...
-----END PUBLIC KEY-----"""

# Attacker changes alg to HS256 and uses your PUBLIC key as HMAC secret
malicious_payload = {"sub": "admin", "exp": 9999999999}
malicious_token = jwt.encode(
    malicious_payload, 
    public_key,  # Using YOUR public key as HMAC secret!
    algorithm="HS256"
)

# Vulnerable validation code will accept this because:
# 1. Token header says "alg": "HS256" 
# 2. Code trusts that and uses HS256 validation
# 3. Public key gets used as HMAC secret
# 4. Signature validates successfully

The attack succeeds because public keys are, well, public. The attacker knows your public key and can use it to forge HMAC signatures.

Why This Works

This attack exploits the fundamental difference between asymmetric (RSA) and symmetric (HMAC) cryptography. RSA uses separate keys for signing and verification, while HMAC uses the same key for both. When you use a public key as an HMAC secret, you’re essentially giving attackers the signing key.

The Fix: Never Trust the Token Header

The solution is straightforward—hardcode your allowed algorithms and never trust what the token claims:

import jwt

def validate_token_secure(token):
    # SECURE: Specify allowed algorithms explicitly
    # Never read from token header
    payload = jwt.decode(
        token,
        key=public_key,
        algorithms=["RS256", "RS384", "RS512"],  # ✅ HARDCODED
        options={"verify_signature": True}
    )
    return payload

Key principles:

  1. Hardcode allowed algorithms—never read from the token
  2. Be specific—don’t use algorithms=None (allows everything)
  3. Stick to one family—don’t mix RSA and HMAC algorithms
  4. Explicitly verify signatures—don’t disable verification

Real-World Prevention Strategies

Environment-Based Algorithm Lists:

For different environments, maintain explicit algorithm lists:

import os

# Configuration based on environment
ALLOWED_ALGORITHMS = {
    'production': ['RS256'],  # Strict in production
    'staging': ['RS256', 'RS384'],  # Slightly more permissive for testing
    'development': ['RS256', 'HS256']  # Local development flexibility
}

def get_allowed_algorithms():
    env = os.getenv('ENVIRONMENT', 'production')
    return ALLOWED_ALGORITHMS.get(env, ['RS256'])

def validate_token(token):
    return jwt.decode(
        token,
        key=get_key(),
        algorithms=get_allowed_algorithms()
    )

Multiple Key Support with Algorithm Constraints:

If you need to support multiple signing keys:

def validate_token_multi_key(token):
    # Still hardcode algorithms, but support multiple keys
    algorithms = ['RS256']
    
    for key_id, key_data in signing_keys.items():
        try:
            payload = jwt.decode(
                token,
                key=key_data['public_key'],
                algorithms=algorithms,
                audience=expected_audience
            )
            return payload
        except jwt.InvalidTokenError:
            continue
    
    raise jwt.InvalidTokenError("No valid key found")

Testing Your JWT Validation:

Always test your JWT validation with these attack scenarios:

import pytest
import jwt

def test_rejects_none_algorithm():
    # Test both formats of none algorithm attacks
    import base64
    import json
    
    header = {'alg': 'none', 'typ': 'JWT'}
    payload = {'sub': 'attacker', 'admin': True}
    
    header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=')
    payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')
    
    # Test both attack formats
    none_tokens = [
        f'{header_b64}.{payload_b64}.',  # Clean none token
        f'{header_b64}.{payload_b64}.eyJmYWtlIjoidHJ1ZSJ9'  # None with fake signature
    ]
    
    for token in none_tokens:
        with pytest.raises(jwt.InvalidTokenError):
            validate_token_secure(token)

def test_rejects_algorithm_substitution():
    # Test RS256 → HS256 attack
    # Create HMAC token using public key as secret
    malicious_token = jwt.encode(
        {"sub": "attacker"},
        public_key,  # Using public key as HMAC secret
        algorithm="HS256"
    )
    
    with pytest.raises(jwt.InvalidTokenError):
        validate_token_secure(malicious_token)

def test_accepts_valid_tokens():
    # Ensure legitimate tokens still work
    valid_token = jwt.encode(
        {"sub": "user123"},
        private_key,
        algorithm="RS256"
    )
    
    payload = validate_token_secure(valid_token)
    assert payload["sub"] == "user123"

Why This Matters

This vulnerability is absolutely devastating in practice. When it exists, attackers get complete admin access to customer management systems—they can view any customer’s data, modify orders, even delete accounts. I’ve seen this give attackers access to CEO accounts and sensitive business data.

This isn’t some obscure theoretical attack—it’s a real vulnerability I keep finding in production applications. The impact is consistently devastating:

  • Complete authentication bypass (bypasses all JWT validation)
  • Privilege escalation from regular user to admin (immediate access)
  • Account takeover for any user ID you can guess or enumerate

The Bigger Picture

This vulnerability illustrates a common pattern in security issues. JWT headers appear to be “metadata” or “configuration,” but they’re actually user-controlled input in structured format. An attacker controls every byte in that header.

It’s easy to make this mistake—structured data can feel more trustworthy than raw user input. This same principle appears in other vulnerability classes:

  • SQL injection (trusting user input in queries)
  • Path traversal (trusting user input for file paths)
  • XXE attacks (trusting user input in XML)
  • And now JWT algorithm confusion

Quick Security Checklist

When implementing JWT validation in Python:

  • Hardcode your algorithms list
  • Never read alg from token headers
  • Use specific algorithms (avoid wildcards)
  • Test with malicious tokens
  • Use verify_signature=True explicitly
  • Validate all other claims (audience, issuer, expiration)
  • Keep PyJWT updated

The concerning aspect of this vulnerability is how easily it can be prevented with a simple change—hardcode your algorithms instead of reading them from the token. However, it’s understandable why developers make this mistake, as it seems logical to read the algorithm from the token header.

The fundamental lesson is to never let user input (including JWT headers) control security decisions. This principle applies across many vulnerability classes including SQL injection, path traversal, and JWT validation.

The key takeaway: define your own validation rules rather than trusting external input to specify how it should be validated.