Files
Rodin 5b9f30e663 Add SSRF, race conditions, JWT security patterns
High-priority patterns from completeness review:
- ssrf.md: metadata endpoints, DNS rebinding, webhook validation
- race-conditions.md: TOCTOU, atomic operations, file/db races
- jwt-security.md: algorithm confusion, kid injection, refresh tokens

Now 16 patterns covering comprehensive web application security.
2026-05-10 23:17:54 -07:00

5.1 KiB

JWT Security

Rule

Verify algorithm, signature, issuer, audience, and expiration. Never trust the header blindly.

Source: RFC 7519: JSON Web Token

Common JWT Attacks

Attack Description Defense
alg=none Header specifies no signature Reject none algorithm
Algorithm confusion RS256 → HS256 with public key as secret Allowlist algorithms
Weak secret Brute-forceable HMAC secret Min 256-bit random secret
Missing expiration Token valid forever Require exp claim
kid injection Header kid used in SQL/file path Sanitize kid value
JKU/X5U injection Fetch attacker's keys Ignore or allowlist URLs

Correct Pattern

import jwt
from datetime import datetime, timedelta

# Configuration - fixed, not from token
ALGORITHM = "RS256"  # Asymmetric preferred
PUBLIC_KEY = load_public_key("keys/public.pem")
PRIVATE_KEY = load_private_key("keys/private.pem")
ISSUER = "https://auth.example.com"
AUDIENCE = "https://api.example.com"

def create_token(user_id: str, roles: list[str]) -> str:
    """Create a JWT with proper claims."""
    now = datetime.utcnow()
    payload = {
        "sub": user_id,
        "roles": roles,
        "iat": now,
        "exp": now + timedelta(hours=1),  # Short expiration
        "iss": ISSUER,
        "aud": AUDIENCE,
    }
    return jwt.encode(payload, PRIVATE_KEY, algorithm=ALGORITHM)

def verify_token(token: str) -> dict:
    """Verify JWT with strict validation."""
    try:
        payload = jwt.decode(
            token,
            PUBLIC_KEY,
            algorithms=[ALGORITHM],  # Allowlist, not from token!
            issuer=ISSUER,
            audience=AUDIENCE,
            options={
                "require": ["exp", "iat", "sub", "iss", "aud"],
                "verify_exp": True,
                "verify_iat": True,
                "verify_iss": True,
                "verify_aud": True,
            }
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise AuthError("Token expired")
    except jwt.InvalidTokenError as e:
        raise AuthError(f"Invalid token: {e}")

Incorrect Pattern

import jwt

# Wrong: algorithm from token header
def bad_verify(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    alg = header["algorithm"]  # Attacker controls this!
    return jwt.decode(token, SECRET, algorithms=[alg])

# Wrong: no algorithm restriction
def bad_verify_2(token: str) -> dict:
    return jwt.decode(token, SECRET)  # Accepts any algorithm

# Wrong: weak secret
SECRET = "secret123"  # Trivially brute-forced

# Wrong: no expiration check
def bad_verify_3(token: str) -> dict:
    return jwt.decode(token, SECRET, options={"verify_exp": False})

# Wrong: kid used in file path
def get_key(token: str):
    header = jwt.get_unverified_header(token)
    kid = header["kid"]
    # Path traversal! kid = "../../../etc/passwd"
    return open(f"keys/{kid}.pem").read()

Algorithm Confusion Attack

# Attack scenario:
# 1. Server uses RS256 (asymmetric)
# 2. Attacker changes header to HS256 (symmetric)
# 3. Attacker signs with the PUBLIC key as HMAC secret
# 4. Vulnerable server verifies with public key
# 5. Signature matches! Token accepted

# Vulnerable code
def vulnerable_verify(token: str, public_key: str):
    # If alg=HS256, this uses public_key as HMAC secret
    return jwt.decode(token, public_key, algorithms=["RS256", "HS256"])

# Secure code - explicit algorithm
def secure_verify(token: str, public_key: str):
    return jwt.decode(token, public_key, algorithms=["RS256"])

Refresh Token Pattern

from secrets import token_urlsafe

# Access token: short-lived JWT (15 min)
# Refresh token: long-lived opaque token in database

def issue_tokens(user_id: str) -> tuple[str, str]:
    access_token = create_token(user_id, exp_minutes=15)
    refresh_token = token_urlsafe(32)  # Opaque, not JWT
    
    # Store refresh token in database with metadata
    RefreshToken.create(
        token_hash=hash(refresh_token),
        user_id=user_id,
        expires_at=datetime.utcnow() + timedelta(days=30),
        device_info=get_device_info()
    )
    
    return access_token, refresh_token

def refresh_access_token(refresh_token: str) -> str:
    """Exchange refresh token for new access token."""
    stored = RefreshToken.query.filter_by(
        token_hash=hash(refresh_token)
    ).first()
    
    if not stored or stored.is_expired or stored.is_revoked:
        raise AuthError("Invalid refresh token")
    
    # Rotate refresh token (one-time use)
    stored.revoke()
    new_access, new_refresh = issue_tokens(stored.user_id)
    
    return new_access, new_refresh

Edge Cases

  • JWTs in URLs leak to logs and referrer headers
  • Token storage: httpOnly cookies vs localStorage (XSS risk)
  • Clock skew between servers affects exp/iat validation
  • Long-lived tokens: implement revocation list
  • nbf (not before) should be validated
  • Nested JWTs (JWE wrapping JWS) need careful handling
  • Don't put sensitive data in JWT payload (base64 is not encryption)