Use APIKeyHeader from fastapi.security:
from fastapi import Depends, HTTPException
from fastapi.security.api_key import APIKeyHeader
API_KEY = "my-secret-key"
api_key_header = APIKeyHeader(name="X-API-Key")
async def verify_api_key(api_key: str = Depends(api_key_header)):
if api_key != API_KEY:
raise HTTPException(status_code=403, detail="Invalid API key")
return api_key
@app.get("/data", dependencies=[Depends(verify_api_key)])
async def get_data():
return {"data": "secret"}
APIKeyHeader extracts the named header and registers the scheme in OpenAPI.
Rule of thumb: use headers for API keys (not query params) — headers are less likely to appear in server access logs and browser history.
Use APIKeyQuery:
from fastapi.security.api_key import APIKeyQuery
api_key_query = APIKeyQuery(name="api_key")
async def verify_key(key: str = Depends(api_key_query)):
if key != settings.api_key:
raise HTTPException(403, "Invalid API key")
@app.get("/export", dependencies=[Depends(verify_key)])
async def export():
...
# GET /export?api_key=abc123
Query param keys are visible in logs, browser history, and URLs shared accidentally — avoid them for sensitive APIs.
Rule of thumb: only use query param API keys for webhooks or download links where adding a header is impossible; always prefer header-based keys.
Declare both dependencies and make them optional; raise if neither is provided:
from fastapi.security import HTTPBearer, APIKeyHeader
bearer = HTTPBearer(auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def authenticate(
bearer_token: HTTPAuthorizationCredentials | None = Depends(bearer),
api_key: str | None = Depends(api_key_header),
):
if bearer_token:
return verify_jwt(bearer_token.credentials)
if api_key:
return verify_api_key(api_key)
raise HTTPException(401, "Authentication required")
@app.get("/data")
async def data(user = Depends(authenticate)):
return user
auto_error=False prevents FastAPI from raising 403 when the header is absent,
giving your code a chance to check the alternative.
Rule of thumb: set auto_error=False on all optional schemes and raise
explicitly at the end — otherwise FastAPI returns 403 as soon as one scheme is missing.
Without HTTPS, API keys and JWTs in headers are transmitted in plaintext — anyone on the same network can intercept them.
In production, TLS termination is handled by the reverse proxy (Nginx, Caddy, AWS ALB) — not Uvicorn itself:
server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location / { proxy_pass http://127.0.0.1:8000; }
}
In FastAPI, redirect HTTP → HTTPS with HTTPSRedirectMiddleware:
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
app.add_middleware(HTTPSRedirectMiddleware)
Rule of thumb: terminate TLS at the load balancer/reverse proxy; use
HTTPSRedirectMiddleware as a safety net to catch direct HTTP connections.
CORS (Cross-Origin Resource Sharing) is a browser security policy. A browser
blocks JavaScript from reading a response from a different origin unless the server
includes the appropriate Access-Control-Allow-* headers.
FastAPI provides CORSMiddleware:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://myapp.example.com"], # never "*" in production with credentials
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)
allow_origins=["*"] is fine for public APIs with no auth; for credentialed
requests (cookies, auth headers) you must list exact origins.
Rule of thumb: list exact allowed origins rather than "*" for any API that
handles auth — browsers won't send credentials to "*" anyway, and it forces
you to be explicit.
CSRF (Cross-Site Request Forgery) attacks are relevant when you use cookie-based
auth. JWT in Authorization: Bearer headers is immune to CSRF because
cross-origin forms can't set custom headers.
For cookie auth, implement the Double Submit Cookie pattern:
@app.post("/login")
async def login(response: Response, ...):
csrf_token = secrets.token_urlsafe(32)
response.set_cookie("session", session_token, httponly=True, secure=True)
response.set_cookie("csrf_token", csrf_token, secure=True) # NOT httponly
return {"csrf_token": csrf_token}
# Client sends X-CSRF-Token header with every mutating request
@app.post("/orders")
async def create_order(
x_csrf_token: str = Header(),
csrf_cookie: str = Cookie(alias="csrf_token"),
):
if x_csrf_token != csrf_cookie:
raise HTTPException(403, "CSRF token mismatch")
SameSite=Lax on session cookies also blocks most CSRF without a token.
Rule of thumb: if using cookie auth, set SameSite=Lax as the minimum; add
the double-submit CSRF token for state-changing endpoints (POST, PUT, DELETE).
Use a dependency that checks a Redis counter:
import time
from fastapi import Depends, HTTPException, Request
async def rate_limit(request: Request, calls: int = 100, period: int = 60):
key = f"rate:{request.client.host}"
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, period)
count, _ = await pipe.execute()
if count > calls:
raise HTTPException(
status_code=429,
detail="Too many requests",
headers={"Retry-After": str(period)},
)
@app.get("/items", dependencies=[Depends(rate_limit)])
async def list_items():
...
For production, use slowapi (wraps limits library with FastAPI integration):
from slowapi import Limiter
limiter = Limiter(key_func=get_remote_address)
Rule of thumb: implement rate limiting at the reverse proxy for brute-force defence; implement in the app for per-user/per-token granular limits.
FastAPI/Uvicorn access logs include the full URL path with query params — API keys in query params will appear in logs.
Mitigations:
- Use headers, not query params, for API keys (they're not in access logs).
- Custom log filter to redact header values:
import logging
class RedactAuthFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = str(record.getMessage())
record.msg = msg.replace(settings.api_key, "***")
return True
- For Uvicorn's access log, use
--no-access-logand write your own structured middleware that explicitly controls what's logged.
Rule of thumb: never put auth credentials in query params; treat all incoming
Authorization header values as secrets and never log them verbatim.
TrustedHostMiddleware rejects requests whose Host header doesn't match an
allowed list, preventing Host header injection attacks.
from starlette.middleware.trustedhost import TrustedHostMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["api.example.com", "*.example.com"],
)
Without it, an attacker who can route traffic to your server can send a request
with Host: evil.com — this can affect password reset links, CORS checks that
rely on the Host header, and server-generated URLs.
Rule of thumb: add TrustedHostMiddleware in production with the exact
hostname(s) — it's a one-liner defence against Host header injection.
Pydantic validates and coerces input types — but it does not sanitise SQL. SQL injection prevention requires using parameterised queries (ORM or raw):
# SAFE — SQLAlchemy parameterises automatically
result = await db.execute(select(User).where(User.name == username))
# SAFE — raw SQL with bound parameters
await db.execute(text("SELECT * FROM users WHERE name = :name"), {"name": username})
# DANGEROUS — string interpolation
await db.execute(f"SELECT * FROM users WHERE name = '{username}'")
Pydantic validates that username is a str — but a string can still contain
SQL metacharacters. Always use parameterised queries regardless of validation.
Rule of thumb: let the ORM/query builder parameterise for you; never interpolate user input directly into SQL strings, even after Pydantic validation.
More Security & Auth interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.