Python API Authentication Comparison — Deep Dive

Technical foundation

Authentication in production Python APIs is more than checking a password. It involves key management, token lifecycle, revocation strategies, rate limiting per identity, and audit logging. This deep dive covers implementation patterns for each major approach with security considerations that textbooks often skip.

API key implementation

Generating and storing keys

Never store API keys in plaintext. Hash them like passwords, but use a fast hash (SHA-256) since keys are high-entropy random strings, not human-chosen passwords:

import secrets
import hashlib

def generate_api_key() -> tuple[str, str]:
    """Returns (plaintext_key, hashed_key). Only show plaintext once."""
    prefix = "sk_live_"
    raw = secrets.token_urlsafe(32)
    plaintext = f"{prefix}{raw}"
    hashed = hashlib.sha256(plaintext.encode()).hexdigest()
    return plaintext, hashed

def verify_api_key(provided: str, stored_hash: str) -> bool:
    return secrets.compare_digest(
        hashlib.sha256(provided.encode()).hexdigest(),
        stored_hash,
    )

Use secrets.compare_digest for constant-time comparison — standard == leaks timing information.

FastAPI dependency for API key auth

from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader

api_key_header = APIKeyHeader(name="X-API-Key")

async def get_api_key_account(
    key: str = Security(api_key_header),
    db: AsyncSession = Depends(get_db),
):
    hashed = hashlib.sha256(key.encode()).hexdigest()
    account = await db.execute(
        select(APIKey).where(APIKey.key_hash == hashed, APIKey.is_active == True)
    )
    result = account.scalar_one_or_none()
    if not result:
        raise HTTPException(status_code=401, detail="Invalid API key")
    
    # Update last_used_at for auditing
    result.last_used_at = datetime.utcnow()
    await db.commit()
    
    return result.account

Key rotation

Production API key management requires rotation without downtime:

  1. Allow multiple active keys per account (primary + secondary).
  2. Client generates a new key, updates their integration, then deactivates the old key.
  3. Set maximum key age policies (90 days) with warnings before expiration.

JWT implementation

Token creation with PyJWT

import jwt
from datetime import datetime, timedelta

SECRET_KEY = "your-256-bit-secret"  # In practice, load from environment
ALGORITHM = "HS256"

def create_access_token(user_id: int, roles: list[str], expires_minutes: int = 15) -> str:
    payload = {
        "sub": str(user_id),
        "roles": roles,
        "iat": datetime.utcnow(),
        "exp": datetime.utcnow() + timedelta(minutes=expires_minutes),
        "jti": secrets.token_urlsafe(16),  # Unique token ID for revocation
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def create_refresh_token(user_id: int, expires_days: int = 30) -> str:
    payload = {
        "sub": str(user_id),
        "type": "refresh",
        "exp": datetime.utcnow() + timedelta(days=expires_days),
        "jti": secrets.token_urlsafe(16),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

Token verification dependency

from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
    db: AsyncSession = Depends(get_db),
):
    try:
        payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, detail="Invalid token")
    
    # Check revocation blocklist
    jti = payload.get("jti")
    if jti and await redis.exists(f"revoked:{jti}"):
        raise HTTPException(401, detail="Token has been revoked")
    
    user = await db.get(User, int(payload["sub"]))
    if not user:
        raise HTTPException(401, detail="User not found")
    
    return user

JWT revocation with Redis

True stateless JWT means no revocation. In practice, you need it for logout and security incidents:

async def revoke_token(token: str):
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    jti = payload["jti"]
    exp = payload["exp"]
    ttl = exp - int(datetime.utcnow().timestamp())
    if ttl > 0:
        await redis.setex(f"revoked:{jti}", ttl, "1")

async def revoke_all_user_tokens(user_id: int):
    """Nuclear option: increment user's token version."""
    await db.execute(
        update(User).where(User.id == user_id).values(token_version=User.token_version + 1)
    )

The version approach invalidates all existing tokens for a user without maintaining a per-token blocklist.

RS256 for microservices

For multi-service architectures, use asymmetric signing. The auth service signs with a private key; other services verify with the public key without needing the secret:

from cryptography.hazmat.primitives import serialization

# Auth service signs
private_key = open("private.pem").read()
token = jwt.encode(payload, private_key, algorithm="RS256")

# Any service verifies
public_key = open("public.pem").read()
payload = jwt.decode(token, public_key, algorithms=["RS256"])

Distribute the public key via a JWKS (JSON Web Key Set) endpoint that services poll periodically.

OAuth 2.0 implementation

Authorization Code Flow with PKCE (for SPAs and mobile)

Using authlib with FastAPI:

from authlib.integrations.starlette_client import OAuth

oauth = OAuth()
oauth.register(
    name="google",
    client_id="...",
    client_secret="...",
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email profile"},
)

@app.get("/auth/google")
async def google_login(request: Request):
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri)

@app.get("/auth/google/callback")
async def google_callback(request: Request):
    token = await oauth.google.authorize_access_token(request)
    user_info = token.get("userinfo")
    
    # Find or create user
    user = await find_or_create_user(email=user_info["email"], name=user_info["name"])
    
    # Issue your own JWT
    access_token = create_access_token(user.id, user.roles)
    refresh_token = create_refresh_token(user.id)
    
    return {"access_token": access_token, "refresh_token": refresh_token}

Client Credentials Flow (machine-to-machine)

For service accounts calling your API:

@app.post("/oauth/token")
async def token_endpoint(
    grant_type: str = Form(...),
    client_id: str = Form(...),
    client_secret: str = Form(...),
):
    if grant_type != "client_credentials":
        raise HTTPException(400, detail="Unsupported grant type")
    
    client = await verify_client_credentials(client_id, client_secret)
    if not client:
        raise HTTPException(401, detail="Invalid client credentials")
    
    token = create_access_token(
        user_id=client.id,
        roles=client.scopes,
        expires_minutes=60,
    )
    return {"access_token": token, "token_type": "bearer", "expires_in": 3600}

Session implementation with Redis

from starlette.middleware.sessions import SessionMiddleware
import redis.asyncio as redis

app.add_middleware(SessionMiddleware, secret_key="...", max_age=3600)

# Or manual session management for more control:
async def create_session(user_id: int) -> str:
    session_id = secrets.token_urlsafe(32)
    session_data = {"user_id": user_id, "created_at": datetime.utcnow().isoformat()}
    await redis_client.setex(
        f"session:{session_id}",
        3600,  # 1 hour TTL
        orjson.dumps(session_data),
    )
    return session_id

async def get_session_user(session_id: str) -> int | None:
    data = await redis_client.get(f"session:{session_id}")
    if not data:
        return None
    return orjson.loads(data)["user_id"]

Security hardening checklist

Regardless of the auth method:

  • Always use HTTPS. Tokens in plaintext HTTP are stolen trivially.
  • Set short expiration for access tokens (15 minutes for JWT, 1 hour for sessions).
  • Use refresh token rotation — issue a new refresh token with every use and invalidate the old one.
  • Rate limit auth endpoints — login, token refresh, and registration are prime brute-force targets.
  • Log authentication events — successful logins, failed attempts, token refreshes, and revocations feed into anomaly detection.
  • Hash API keys and session IDs before storing them.
  • Use Secure, HttpOnly, and SameSite flags on session cookies.

Migration patterns

Moving from sessions to JWT (or vice versa):

  1. Support both simultaneously — check for JWT in the Authorization header, fall back to session cookie.
  2. New clients use the target method. Existing clients continue with the old method.
  3. Set a deprecation timeline and monitor usage of the old method.
  4. Remove the old method once traffic drops to zero.
async def get_current_user(request: Request):
    # Try JWT first
    auth_header = request.headers.get("authorization")
    if auth_header and auth_header.startswith("Bearer "):
        return await verify_jwt(auth_header[7:])
    
    # Fall back to session cookie
    session_id = request.cookies.get("session_id")
    if session_id:
        return await verify_session(session_id)
    
    raise HTTPException(401, detail="Authentication required")

The one thing to remember: No single auth method fits all cases — use API keys for developer integrations, JWT for stateless services, sessions for browser apps, and OAuth for third-party access — and always plan for revocation, rotation, and migration from day one.

pythonapiauthenticationsecurity

See Also