Secure JWT Authentication in FastAPI Starts With Configuration
FastAPI makes login endpoints easy, but failures usually start earlier: hardcoded SECRET_KEY values, missing issuer and audience checks, and secrets baked into Docker images. This guide covers Pydantic Settings, PyJWT (or python-jose), explicit claim validation, pytest sketches, and Docker/Kubernetes deployment notes.
Use the JWT Secret Generator and Python secret guide. Compare HS256 and RS256 in HS256 vs RS256 before committing to an algorithm.
Threat Model for API-First FastAPI Services
Threats facing FastAPI APIs include:
- Secret leakage from repos, logs, or image layers — inject via env or Env Vars vs Vault.
- Token forgery when verification skips signatures or accepts weak algorithms.
- Cross-service replay without per-service
audandissenforcement. - Stolen refresh tokens — mitigate with rotation and server-side sessions (Refresh Token).
- Credential stuffing on
/auth/token— rate limit and monitor failures.
JWTs prove authentication, not authorization. Enforce roles after verification and never trust client-supplied user IDs without cryptographic proof. Because access tokens are self-contained, compromise of a signing key affects every service that trusts it — isolate keys per environment and monitor for anomalous token minting. Use the JWT Validator to debug non-production tokens.
Pydantic Settings: Never Hardcode SECRET_KEY
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
jwt_secret: str = Field(..., min_length=32)
jwt_algorithm: str = "HS256"
jwt_issuer: str = "https://auth.example.com"
jwt_audience: str = "https://api.example.com"
access_token_expire_minutes: int = 15
@field_validator("jwt_algorithm")
@classmethod
def allowed_algs(cls, v: str) -> str:
if v not in {"HS256", "HS384", "HS512"}:
raise ValueError("Unsupported JWT algorithm")
return v
settings = Settings()Fail fast at startup if JWT_SECRET is missing. Inject production secrets from your orchestrator — see How to Store JWT Secrets Securely.
For local development only, load .env via python-dotenv (or Pydantic Settings env_file=".env" as above). Never commit .env. In production prefer platform env injection, AWS Secrets Manager, Railway variables, Docker -e, or Kubernetes Secrets — not a checked-in dotenv file.
HTTPBearer works for simple Bearer APIs; OAuth2PasswordBearer is a better fit when you also expose a password token endpoint that OpenAPI can document.
Token Endpoint and Access Token Creation
Expose OAuth2-compatible issuance for OAuth2PasswordBearer:
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
router = APIRouter(prefix="/auth", tags=["auth"])
def create_access_token(subject: str, *, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject, "role": role,
"iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"iat": now, "exp": now + timedelta(minutes=settings.access_token_expire_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
@router.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form.username, form.password)
if not user:
raise HTTPException(status_code=401, detail="invalid_credentials")
return {"access_token": create_access_token(str(user.id), role=user.role), "token_type": "bearer"}Add a refresh endpoint that validates a separate token type and rotates it — see Access Token vs Refresh Token.
Issuer, Audience, and Algorithm Validation
Centralize decode logic for every protected route:
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(
token, settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
options={"require": ["exp", "sub", "iss", "aud"]},
)
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=401, detail="token_expired") from exc
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=401, detail="invalid_token") from excpython-jose accepts the same parameters. Wire into FastAPI dependencies:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
return decode_access_token(token)Protect routes with current_user: dict = Depends(get_current_user).
Pytest Sketch: Prove Verification Behavior
import pytest
from datetime import datetime, timedelta, timezone
import jwt
from fastapi.testclient import TestClient
client = TestClient(app)
def test_protected_route_requires_token():
assert client.get("/api/me").status_code == 401
def test_wrong_audience_rejected():
token = jwt.encode(
{"sub": "1", "iss": settings.jwt_issuer, "aud": "https://evil.example.com",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5)},
settings.jwt_secret, algorithm=settings.jwt_algorithm,
)
assert client.get("/api/me", headers={"Authorization": f"Bearer {token}"}).status_code == 401
@pytest.fixture
def valid_token():
return jwt.encode(
{"sub": "42", "role": "user", "iss": settings.jwt_issuer, "aud": settings.jwt_audience,
"exp": datetime.now(timezone.utc) + timedelta(minutes=5)},
settings.jwt_secret, algorithm=settings.jwt_algorithm,
)
def test_valid_token_grants_access(valid_token):
res = client.get("/api/me", headers={"Authorization": f"Bearer {valid_token}"})
assert res.status_code == 200 and res.json()["sub"] == "42"Use ephemeral CI secrets; override Settings via environment variables in test jobs.
Docker and Kubernetes Configuration Notes
Pass secrets at runtime, never at image build time:
ENV PYTHONUNBUFFERED=1
# JWT_SECRET supplied by docker run -e or compose secretsservices:
api:
environment:
JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: https://auth.example.com
JWT_AUDIENCE: https://api.example.com
secrets: [jwt_secret]In Kubernetes, mount Secret objects via envFrom.secretRef and restrict RBAC so only the workload service account can read signing material:
apiVersion: v1
kind: Secret
metadata:
name: jwt-signing-key
stringData:
JWT_SECRET: "<from-external-secrets-operator>"
---
spec:
template:
spec:
containers:
- envFrom:
- secretRef:
name: jwt-signing-keyPrefer External Secrets Operator, Sealed Secrets, or cloud KMS over committing manifests with real values. Use distinct keys per environment and plan rotation with kid headers (How to Rotate JWT Secrets). Never bake JWT_SECRET into the Docker image layer — anyone with registry access could extract it.
Common Pitfalls
SECRET_KEY = "change-me"— let Pydantic raise at startup instead.- Shared
audacross unrelated APIs — scope tokens per service. - Missing
options={"require": [...]}— PyJWT may accept incomplete claims. - Logging Authorization headers — tokens become credentials in log stores.
- Long access TTL — keep token expiration short.
Use the JWT Encoder only with development keys.
Production Checklist
- [ ] Secrets via Pydantic Settings; minimum 256-bit entropy
- [ ]
/auth/tokenrate limited; passwords hashed with Argon2 or bcrypt - [ ] Single shared decode function enforcing
algorithms,issuer,audience - [ ] Required claims:
exp,sub,iss,aud - [ ]
get_current_useron every protected router - [ ] Pytest coverage for expired, tampered, and wrong-audience tokens
- [ ] Docker/K8s secrets external to image layers
- [ ] Monitoring on 401 spikes and failed login volume
Frequently Asked Questions
PyJWT or python-jose with FastAPI?
PyJWT pairs cleanly with explicit algorithm and claim options. python-jose works too but needs identical strict decode parameters. Wrap either behind decode_access_token so routes stay library-agnostic.
Can I use the same Settings class for dev and production?
Yes, with different env files or injected variables. Never disable aud checks in dev — use shorter TTLs or separate issuers instead.
How do I test JWT auth in CI without real secrets?
Set JWT_SECRET to a random 32+ byte value in the CI environment. Fixtures mint tokens with the same settings instance the app uses.
Should FastAPI services use HS256 or RS256?
HS256 when this service signs and verifies. RS256 when multiple services verify but only an auth service signs — publish keys via JWKS. Microservices often consume RS256 from a central IdP.