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:
- Allow multiple active keys per account (primary + secondary).
- Client generates a new key, updates their integration, then deactivates the old key.
- 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, andSameSiteflags on session cookies.
Migration patterns
Moving from sessions to JWT (or vice versa):
- Support both simultaneously — check for JWT in the Authorization header, fall back to session cookie.
- New clients use the target method. Existing clients continue with the old method.
- Set a deprecation timeline and monitor usage of the old method.
- 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.
See Also
- Python Api Caching Layers Why Python APIs remember answers to common questions — like a teacher who writes frequent answers on the whiteboard.
- Python Api Error Handling Standards Why good error messages from your Python API are like clear road signs — they tell callers exactly what went wrong and what to do next.
- Python Api Load Testing Testing how many people your Python API can handle at once — like stress-testing a bridge before opening it to traffic.
- Python Api Monitoring Observability How Python APIs keep track of their own health — like a car dashboard that warns you before the engine overheats.
- Python Request Validation Patterns How Python APIs check incoming data before trusting it — like a bouncer checking IDs at the door.