In this post
What this post covers
Middleware wraps the whole application: it sees every request on the way in and every response on the way out, including 404s and errors. That makes it the right place for cross-cutting concerns such as request IDs, timing, CORS, compression and trusted hosts, and the wrong place for business logic or per-endpoint behaviour, which belong in dependencies. This part covers the onion model, the two ways to write middleware (and why the easy way has costs), and the built-in middleware you should configure carefully, CORS above all.
An onion, or airport security
Each layer of middleware is a checkpoint that every passenger passes on the way in and again on the way out, in reverse order. The outermost layer sees everyone first and last. A checkpoint can turn a passenger away (return a response early), add a stamp (a header), or time the whole trip.
1. The onion: order matters
the last middleware added runs first on the way in
from fastapi import FastAPI
from fastapi.testclient import TestClient
trace = []
app = FastAPI()
@app.middleware("http")
async def first_added(request, call_next):
trace.append("first_added: in")
response = await call_next(request)
trace.append("first_added: out")
return response
@app.middleware("http")
async def second_added(request, call_next):
trace.append("second_added: in")
response = await call_next(request)
trace.append("second_added: out")
return response
@app.get("/")
def root():
trace.append("endpoint")
return {}
TestClient(app).get("/")
print(" → ".join(trace))
Output
second_added: in → first_added: in → endpoint → first_added: out → second_added: out
2. Two ways to write middleware
@app.middleware("http") (BaseHTTPMiddleware)
Easy: you get a Request and call_next. It runs the rest of the app in a separate task, and it wraps and re-streams the response, which costs time on every request. Historically it has had edge cases with streaming, background tasks and context variables.
Pure ASGI middleware
A class with __call__(scope, receive, send) that wraps send to see the response start. It takes more lines, adds almost no overhead, streams correctly, and is what Starlette's own middleware uses. Prefer it for anything on every request.
request ID + timing as pure ASGI middleware, with a context variable for logs
import contextvars
import time
import uuid
from fastapi import FastAPI
from fastapi.testclient import TestClient
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
class RequestContextMiddleware:
"""Adds X-Request-ID and Server-Timing to every HTTP response, and exposes the id to all code."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http": # websockets and lifespan pass straight through
return await self.app(scope, receive, send)
headers = dict(scope["headers"])
rid = headers.get(b"x-request-id", b"").decode() or uuid.uuid4().hex[:16]
token = request_id_var.set(rid)
start = time.perf_counter()
async def send_wrapper(message):
if message["type"] == "http.response.start": # add headers as the response begins
ms = (time.perf_counter() - start) * 1000
message.setdefault("headers", [])
message["headers"] += [(b"x-request-id", rid.encode()),
(b"server-timing", f"app;dur={ms:.1f}".encode())]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
request_id_var.reset(token)
app = FastAPI()
app.add_middleware(RequestContextMiddleware)
def log(msg): # any code, any depth, can see the request id
print(f"[{request_id_var.get()}] {msg}")
@app.get("/orders/{order_id}")
def get_order(order_id: int):
log(f"loading order {order_id}")
return {"id": order_id}
client = TestClient(app)
r = client.get("/orders/7", headers={"X-Request-ID": "gw-8f2c"}) # propagated from a gateway
print(r.headers["x-request-id"], r.headers["server-timing"].split(";")[0])
r = client.get("/missing") # middleware sees 404s too
print(r.status_code, "has id:", len(r.headers["x-request-id"]) == 16)
Output
[gw-8f2c] loading order 7
gw-8f2c app
404 has id: True
what each style costs per request
import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
class PassThrough: # pure ASGI: does nothing
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
await self.app(scope, receive, send)
def build(kind, layers=5):
app = FastAPI()
for _ in range(layers):
if kind == "pure ASGI":
app.add_middleware(PassThrough)
elif kind == "BaseHTTPMiddleware":
@app.middleware("http")
async def passthrough(request, call_next):
return await call_next(request)
@app.get("/")
async def root():
return {"ok": True}
return app
for kind in ("none", "pure ASGI", "BaseHTTPMiddleware"):
client = TestClient(build(kind))
client.get("/")
start = time.perf_counter()
for _ in range(500):
client.get("/")
print(f"{kind:20} 5 layers: {(time.perf_counter() - start) / 500 * 1000:.2f} ms per request")
Output
none 5 layers: 4.06 ms per request
pure ASGI 5 layers: 4.28 ms per request
BaseHTTPMiddleware 5 layers: 5.82 ms per request
Most of each number is the test client itself. Read the differences: five pure-ASGI layers added
roughly 0.1 ms each, while five BaseHTTPMiddleware layers added roughly 0.5 ms each. On a service
doing thousands of requests per second with a stack of “simple” @app.middleware functions, that is
real CPU spent on doing nothing.
3. CORS: configure it exactly
Browsers block a page on https://shop.example.com from reading responses from your API on another
origin unless the API says it may, through CORS headers. CORSMiddleware answers the browser’s
preflight OPTIONS requests and adds those headers. One misconfiguration is genuinely dangerous:
a strict allow-list vs the wildcard-with-credentials trap
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
def api(**cors):
app = FastAPI()
app.add_middleware(CORSMiddleware, **cors)
@app.get("/me")
def me():
return {"user": "asha", "balance": 12_400}
return TestClient(app)
def preflight(client, origin):
r = client.options("/me", headers={"Origin": origin, "Access-Control-Request-Method": "GET"})
return r.status_code, r.headers.get("access-control-allow-origin"), r.headers.get("access-control-allow-credentials")
strict = api(allow_origins=["https://shop.example.com"], allow_credentials=True,
allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"], max_age=600)
print("strict, our site: ", preflight(strict, "https://shop.example.com"))
print("strict, evil site: ", preflight(strict, "https://evil.example"))
danger = api(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
print("'*' + credentials: ", preflight(danger, "https://evil.example"), " <- any site, WITH cookies")
Output
strict, our site: (200, 'https://shop.example.com', 'true')
strict, evil site: (400, None, 'true')
'*' + credentials: (200, 'https://evil.example', 'true') <- any site, WITH cookies
Browsers do not allow a literal * together with credentials, so Starlette reflects the
requesting origin instead. As the output shows, any website then gets
Access-Control-Allow-Origin: https://evil.example with credentials allowed. A malicious page
can make logged-in requests on the victim's behalf and read the answers. List your front-end origins
explicitly, from settings. Use allow_origin_regex only with a tightly anchored pattern.
4. The other built-in middleware
| Middleware | Use | Watch out |
|---|---|---|
GZipMiddleware(minimum_size=1000) | compress large JSON | often better done at the proxy/CDN; skip already-compressed content |
TrustedHostMiddleware(allowed_hosts=[...]) | reject requests with unexpected Host headers (host-header injection, cache poisoning) | include health-check hosts your platform uses |
HTTPSRedirectMiddleware | redirect http → https | behind a TLS-terminating proxy, use proxy headers instead (part 23) |
uvicorn --proxy-headers | trust X-Forwarded-For/Proto from your proxy for client IPs and schemes | set --forwarded-allow-ips to your proxy only, or clients can spoof IPs |
5. Middleware or dependency?
| Concern | Put it in | Because |
|---|---|---|
| request IDs, timing, access logs, CORS, compression, security headers | middleware | must apply to every response, including 404s and errors |
| authentication, authorization, DB sessions, rate limits per route | dependencies | need route context, show up in OpenAPI, can be overridden in tests |
| error formatting | exception handlers | part 09 |
Recap
- Middleware wraps everything; the last one added is the outermost.
- Prefer pure ASGI middleware for per-request concerns: less overhead (measured above), correct streaming;
contextvarscarry the request id everywhere. - CORS: an explicit origin list; never
"*"with credentials. - Middleware for universal concerns, dependencies for route-specific ones, and handlers for error shapes.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.