Introduction
Every request that hits your FastAPI application passes through a chain of middleware before reaching your handler. That's where cross-cutting concerns like CORS validation, request tracking, and performance monitoring live—and getting them right is what separates a backend that's easy to operate from one that becomes a support nightmare. This article covers FastAPI middleware and CORS: what developers should know, focusing on the practical patterns we use in production systems handling real traffic.
Middleware is deceptively simple: wrap the incoming request, do something, call the next layer, capture the response, and return it. But the execution order, performance implications, and interaction with your logging strategy matter far more than most developers realize. Let's dig into the patterns that actually work at scale.
CORS — Allow only your frontend origins
Cross-Origin Resource Sharing (CORS) is a security mechanism built into browsers. Without proper configuration, your frontend running on https://app.example.com cannot make requests to your backend on a different origin. FastAPI makes this straightforward via the CORSMiddleware, but the details matter.
Start with the most restrictive configuration that works for your architecture:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
max_age=86400,
)
@app.get("/health")
def health():
return {"status": "ok"}Let's break down each parameter:
- allow_origins: List of exact origins that can access your API. In development, you might add
http://localhost:3000. In production, be specific. If you use a CDN or multiple subdomains, add each one explicitly. - allow_credentials: Set to
Trueif your frontend sends cookies or authorization headers. WhenTrue, you cannot use wildcard origins like*. - allow_methods: Restrict to the HTTP methods your API actually uses. Don't use wildcard
["*"]unless you have a very good reason. - allow_headers: Whitelist headers your frontend sends. Common ones are
Content-Type,Authorization, and any custom headers your app needs. - max_age: How long (in seconds) the browser caches the CORS preflight response. Defaults to 600 seconds; increase it to reduce preflight requests.
In a real scenario, you might parameterize origins by environment:
import os
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
)This lets you deploy the same image with different CORS_ORIGINS env vars for dev, staging, and production. Always validate that your frontend URL is in the list before deploying—mismatched CORS setup is a common cause of silent request failures.
Request IDs — Middleware that adds an ID header and log context
When you're investigating an issue in production, a single request often spans multiple log lines, databases queries, external API calls, and worker tasks. A request ID ties all of that together. Without one, debugging is guesswork.
Create a middleware that generates or extracts a request ID, attaches it to the request scope, and makes it available to your logging system:
import uuid
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
import logging
logger = logging.getLogger(__name__)
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
app = FastAPI()
app.add_middleware(RequestIDMiddleware)Now, every request has a unique ID. The middleware checks for an incoming X-Request-ID header (useful if your frontend or a load balancer already generated one) and falls back to a new UUID. It also returns the ID in the response header so the client can reference it in support tickets.
To use it in your route handlers, integrate with your logging system. With Python's standard logging and a context-aware logger, inject the request ID:
import contextvars
from fastapi import FastAPI, Depends, Request
request_id_context: contextvars.ContextVar[str] = contextvars.ContextVar(
"request_id", default="no-id"
)
class LoggerAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs):
return f"[{request_id_context.get()}] {msg}", kwargs
logger = logging.getLogger(__name__)
logger_adapter = LoggerAdapter(logger, {})
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
token = request_id_context.set(request_id)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
finally:
request_id_context.reset(token)
@app.get("/users/{user_id}")
async def get_user(user_id: int):
logger_adapter.info(f"Fetching user {user_id}")
# Your logic here
return {"user_id": user_id}Now every log line automatically includes the request ID without passing it through function parameters. This is invaluable for correlating logs across services in a microservices architecture.
Timing — Measure latency per route and export it as metrics
You need to know which routes are slow. A timing middleware measures request duration and exports it as metrics so you can alert on p99 latencies or identify bottlenecks:
import time
from starlette.middleware.base import BaseHTTPMiddleware
from prometheus_client import Histogram, Counter
request_duration = Histogram(
"http_request_duration_seconds",
"HTTP request latency",
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0),
labelnames=["method", "path", "status_code"],
)
request_count = Counter(
"http_requests_total",
"Total HTTP requests",
labelnames=["method", "path", "status_code"],
)
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = time.time() - start
# Label sanitization: avoid cardinality explosion from dynamic IDs
path = request.url.path
if "/users/" in path:
path = "/users/{user_id}"
labels = {
"method": request.method,
"path": path,
"status_code": response.status_code,
}
request_duration.labels(**labels).observe(duration)
request_count.labels(**labels).inc()
response.headers["X-Process-Time"] = str(duration)
return response
app = FastAPI()
app.add_middleware(TimingMiddleware)This exports Prometheus metrics that you can scrape and visualize in Grafana. The key trick is label sanitization: if you label by the literal path (e.g., /users/123, /users/456), you'll create a new metric series for every user ID, exhausting your metrics storage. Instead, normalize dynamic segments to a template like /users/{user_id}.
In production, you'll expose these metrics on a separate endpoint:
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)Then your monitoring stack scrapes GET /metrics on a schedule and builds dashboards and alerts.
Keep it light — Heavy middleware runs on every request, including health checks
Middleware runs on every request, including GET /health that your load balancer might hit 10 times per second. A slow middleware becomes a bottleneck. Be intentional about what lives there.
For example, don't fetch secrets from a remote vault in middleware. Cache them at startup. Don't make external API calls. Don't do CPU-intensive work. If your middleware does any of those things, you'll see latency creep.
Also consider whether to skip middleware for certain routes. A health check endpoint should be fast and minimal:
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip timing for health checks
if request.url.path in ["/health", "/readiness"]:
return await call_next(request)
start = time.time()
response = await call_next(request)
duration = time.time() - start
# ... rest of timing logic
return responseAlternatively, use a separate "internal" or "health" router that doesn't attach certain middleware:
from fastapi import APIRouter
app = FastAPI()
# Public routes get all middleware
public_routes = APIRouter()
@public_routes.get("/users")
async def list_users():
return []
# Health routes skip middleware
health_routes = APIRouter()
@health_routes.get("/health")
async def health():
return {"status": "ok"}
app.include_router(public_routes, prefix="/api")
app.include_router(health_routes)In this pattern, the health endpoint is at the top level and routes under /api carry the middleware overhead. Your load balancer can ping /health without incurring timing, logging, or tracing overhead.
Common mistakes
- Using wildcard CORS with credentials: If
allow_origins=["*"]andallow_credentials=True, the browser will reject the response. Either be specific with origins or omit credentials. - Not sanitizing metric labels: Labeling by literal paths or user IDs creates unbounded cardinality. Always normalize dynamic segments to templates.
- Middleware that blocks: In an async framework, a middleware that does blocking I/O (like
requests.get()instead of `aiohttp`) stalls the entire event loop. Use async-compatible libraries. - Ordering issues: Middleware runs in reverse order of addition. If you add CORS, then request ID, then timing, requests are processed in the timing → request ID → CORS order. Make sure the order matches your intent.
- Extracting request IDs from the wrong place: Don't look for a request ID only in headers; also check query params or body if your architecture sends it there. Be defensive.
Key takeaways
- Set CORS origins explicitly by environment. Never use wildcard
*withallow_credentials=True. - Add request IDs to tie logs together across your system. Use
contextvarsto inject them into logging without passing them as parameters. - Measure request latency and export as Prometheus metrics. Sanitize labels to avoid cardinality explosion.
- Keep middleware fast. Skip it for health checks. Avoid blocking I/O. Prefer async libraries.
- Test middleware ordering in development. A middleware executed at the wrong time can silently break your app.






Comments
Loading comments…