Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Middleware

Middleware wraps the whole application: it sees every request on the way in and every response on the way out, including 404s and errors.

In this post

What this post covers
  1. 1. The onion: order matters
  2. 2. Two ways to write middleware
  3. 3. CORS: configure it exactly
  4. 4. The other built-in middleware
  5. 5. Middleware or dependency?
  6. Recap

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

timing (added last → outermost) request id CORS (added first → innermost of these) router + endpoint

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
Never combine allow_origins=["*"] with allow_credentials=True

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

MiddlewareUseWatch out
GZipMiddleware(minimum_size=1000)compress large JSONoften 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
HTTPSRedirectMiddlewareredirect http → httpsbehind a TLS-terminating proxy, use proxy headers instead (part 23)
uvicorn --proxy-headerstrust X-Forwarded-For/Proto from your proxy for client IPs and schemesset --forwarded-allow-ips to your proxy only, or clients can spoof IPs

5. Middleware or dependency?

ConcernPut it inBecause
request IDs, timing, access logs, CORS, compression, security headersmiddlewaremust apply to every response, including 404s and errors
authentication, authorization, DB sessions, rate limits per routedependenciesneed route context, show up in OpenAPI, can be overridden in tests
error formattingexception handlerspart 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; contextvars carry 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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries