FastAPI Middleware — Logging, CORS and Custom Middleware
Master FastAPI middleware: configure production-ready CORSMiddleware, implement custom request/response logging, and understand execution order for high-performance ASGI apps..
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
- Middleware in FastAPI intercepts every HTTP request and response.
- Use
app.add_middleware()for global configurations like CORS or GZip compression. - For custom logic, use
@app.middleware('http')decorator to wrapcall_next. - Execution follows an 'Onion' pattern: last added runs first for requests, last for responses.
- Each middleware adds ~0.1-1ms overhead; 20+ layers can add noticeable latency.
- CORS errors in production are often caused by middleware ordering or missing allowed headers.
Think of FastAPI middleware like security checkpoints at an airport: every passenger (request) must pass through them before reaching the gate (your route handler), and they go through the same checkpoints on the way out. Each checkpoint can inspect bags, add a boarding pass stamp, or turn someone away entirely—without the gate agent ever knowing it happened. The order of these checkpoints matters because once someone is turned away at an early checkpoint, later ones never get a chance to run.
In production at TheCodeForge, we treat middleware as the 'Defensive Perimeter' of our services. Middleware handles cross-cutting concerns—logic that shouldn't clutter your business endpoints. Whether you're enforcing Cross-Origin Resource Sharing (CORS) policies, injecting global trace IDs for distributed logging, or measuring request latency, middleware provides a centralized point of control that wraps your entire ASGI application.
But here's the thing: one wrong middleware can silently drop all requests. That's not theory—it's a 2 AM pager call. Understanding execution order, short-circuiting behavior, and how to avoid blocking the event loop is what separates a working API from one that crashes under load.
This article covers the patterns we use, the production traps we've fixed, and the debugging steps that get you back online fast.
How FastAPI CORS Middleware Actually Works
CORS middleware in FastAPI intercepts every incoming HTTP request before it reaches your route handler and adds the necessary CORS headers to the response. It's a WSGI/ASGI middleware that wraps the entire application, inspecting the Origin header and deciding whether to include Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers based on your configuration. The core mechanic is simple: it's a preflight handler for OPTIONS requests and a response header injector for all other requests.
In practice, FastAPI's CORSMiddleware operates at the ASGI level, meaning it runs before any path operation decorator or dependency injection. It matches origins against a whitelist using exact string comparison or regex patterns. The middleware caches the allowed origins in memory, so lookups are O(1) after initialization. A critical detail: if you set allow_origins=["*"], the middleware will echo back the request's Origin header — it does not blindly send a wildcard when credentials are involved, because the CORS spec forbids wildcard with credentials.
You should use FastAPI's CORSMiddleware when your API serves a browser-based frontend on a different domain, port, or protocol. Without it, browsers will block cross-origin requests entirely. In production, never use allow_origins=["*"] with allow_credentials=True — the browser will reject the response. Instead, explicitly list your frontend domains. This middleware is also the correct place to handle preflight caching via Access-Control-Max-Age to reduce OPTIONS request volume.
CORS Middleware: Securing Cross-Origin Traffic
CORS is a security feature, not an error. When your frontend (e.g., React on port 3000) tries to talk to your FastAPI backend (port 8000), the browser blocks the request unless the server explicitly permits it. For production, never use ['*']. Always whitelist specific, trusted domains.
But it gets tricky: when allow_credentials=True, the Access-Control-Allow-Origin response header must be a single origin, not a wildcard. The browser enforces this. And if you have a middleware that returns a 403 for unauthenticated requests before CORS headers are set, the frontend gets a CORS error—not a 403. That's a common head-scratcher.
allow_credentials=True and allow_origins=[''], modern browsers will reject the preflight response. The spec requires an explicit origin when credentials are involved.
- Always use explicit origins in production.
- For development, list http://localhost:3000 explicitly; don't use even locally if you need cookies.add_middleware calls).allow_origins=[specific_domain] with allow_credentials=True['*'] is acceptable, but still prefer explicit list for loggingCustom Middleware — Performance Logging
Custom middleware uses the call_next pattern. This allows you to run code before the request reaches your route and after the response has been generated. This is the ideal place to calculate 'Time to First Byte' (TTFB) or inject unique request identifiers for log aggregation.
But there's a subtle trap: if you do heavy synchronous work (like hashing or JSON serialization) in the middleware, you block the entire event loop. All concurrent requests wait. Always push heavy work to a thread pool or make it async-friendly.
- The middleware added last wraps the outermost layer. It sees the request first and the response last.
- Each middleware calls
call_nextto hand off to the next inner layer. - Code before
await call_next(request)runs during the request phase (inward). - Code after
await call_next(request)runs during the response phase (outward). - Short-circuiting means a middleware returns a response without calling
call_next, cutting the onion.
time.sleep(0.5)) in the async path, the entire event loop stalls.asyncio.to_thread for blocking calls, or shift heavy work to a background task.time.perf_counter() not time.time() for nanosecond precision.Understanding Middleware Execution Order: The Onion Model
FastAPI middleware follows a Last-In-First-Out (LIFO) order for requests and First-In-First-Out (FIFO) for responses. The last middleware you add with is the first to process the request, and the last to process the response. This is identical to how ASP.NET Core and Express middleware work.app.add_middleware()
This matters because of the onion model: if middleware A adds CORS headers and middleware B injects a trace ID, middleware B must be added before A (so B runs first on the request) to ensure the trace ID is available before CORS processing. But then CORS will run last on the response, meaning the trace ID header might not be visible to the frontend if CORS strips unknown headers. You need to explicitly allow X-Forge-Trace-ID in allow_headers.
- Requests travel through middleware in stack order (LIFO).
- Add middleware A first, then B. Request hits B first, then A.
- Response travels through in queue order (FIFO).
- So B sees the response first, then A.
- If you want something to run after everything, add it last (innermost).
Short-Circuiting Middleware: Early Returns and IP Blocking
Sometimes you don't want to forward a request at all—like when an IP is on a blocklist, or you need to rate-limit a client. In FastAPI middleware, you can short-circuit by returning a Response object directly without calling call_next. This skips the rest of the middleware chain and the route handler, sending the response straight back to the client.
But there's a catch: if you short-circuit, you must ensure that any mandatory middleware (like CORS) hasn't been skipped. Because of the onion order, if you short-circuit in an outer middleware, no inner middleware runs. That means no CORS headers on the block response, and the client may not see the 403—just a CORS error. The solution: add CORS middleware as the outermost (first added) so it always runs, even on short-circuited responses.
app.add_middleware(IPBlockerMiddleware, ...) after app.add_middleware(CORSMiddleware, ...)) so that CORSMiddleware wraps the IP blocker and adds headers to its responses.Error Handling Middleware: Global Exception Catching
FastAPI provides exception handlers, but a middleware can also catch exceptions that bubble up from routes or other middlewares. By wrapping await call_next(request) in a try-except, you can catch any unhandled exception and return a consistent JSON error response. This is especially useful for catching ValidationError, HTTPException, or unexpected server errors.
But careful catching everything can mask bugs. We log the error and return a 500 with a generic message, but preserve the trace ID for correlation.
@app.exception_handler catches exceptions after middleware has already processed them. A middleware try-except around call_next catches exceptions during middleware processing. Use both, but be aware that if your middleware catches an exception and returns a response, the FastAPI exception handler will not run.HTTPException after logging, or check the exception type.HTTPException pass through to FastAPI's handler; catch only Exception for truly unexpected errors.Testing Middleware with FastAPI TestClient
You can't ship middleware without tests. FastAPI's TestClient provides a way to simulate requests and inspect responses without running the server. You can test that headers are set, that short-circuiting works, and that logging is called.
But a common mistake: the TestClient doesn't run ASGI middleware in the exact same way as a production server. Some middleware that relies on request.client.host or advanced ASGI features may behave differently in tests. We'll show you how to mock them and what to watch out for.
TestClient does not set request.client. If your middleware uses request.client.host, you need to mock it. Use unittest.mock.patch to mock fastapi.Request.client as a property. Alternatively, design your middleware to read from a trusted header like X-Forwarded-For first, which is easier to set in tests.CORS Preflight Requests: The OPTIONS Dance Your Browser Never Told You About
Your browser doesn't just blindly send cross-origin POST requests with custom headers. It first checks with the server using an HTTP OPTIONS request called a 'preflight.' If the server doesn't respond with the right CORS headers, the browser aborts the actual request before it even leaves the network stack.
This is where most CORS bugs live. You configure CORSMiddleware for GET and POST, but your frontend sends an Authorization header. The browser sends a preflight to check if that header's allowed. If your middleware doesn't include allow_headers=['Authorization'], the preflight fails silently and your JavaScript gets a cryptic CORS error.
The preflight response must include Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. FastAPI's CORSMiddleware handles this automatically when you configure it correctly — but only if you know the dance exists. Skip a header, and you're debugging for hours.
Allowing Any Origin With Credentials: The False Economy of the Wildcard
Setting allow_origins=["*"] sounds like a quick fix. It's not. The CORS spec explicitly forbids using a wildcard when allow_credentials=True. Why? Because a wildcard tells the browser to accept any origin, but credentials (cookies, Authorization headers) must only be sent to known origins. Combining them is a security hole the spec refuses to open.
When you try to set both, FastAPI's CORSMiddleware throws a ValueError at startup. Not at runtime — at startup. You'll see: 'CORS middleware configuration: allow_origins must not be a wildcard when allow_credentials is True.'
The fix is explicit. List every origin you trust. If you have multiple environments (staging, production, local dev), maintain a list. Use environment variables. Never ship with credentials + wildcard. The JavaScript console error you'll get on the frontend is useless: 'Access to fetch at ... has been blocked by CORS policy.' No mention of the credentials conflict.
If you truly need dynamic origins (e.g., a multi-tenant SaaS), implement a custom origin validation function. FastAPI's CORSMiddleware accepts a callable for allow_origins. Validate the request's Origin header against a database or config. That's production-grade.
CORS Middleware Is Not a Security Wall — It's a Browser Policy Enforcer
Juniors treat CORS middleware like a firewall. It's not. The browser enforces CORS, not your server. If you curl your API from a terminal, CORS headers are ignored. No browser, no CORS.
FastAPI's CORSMiddleware appends Access-Control-Allow-Origin to responses. That tells the browser: "This origin is allowed to read the response." Without it, the browser blocks JavaScript from reading cross-origin data—even if the server sends it.
This is critical for SPAs, mobile backends, and third-party API consumers. But understand the weakness: any non-browser client (curl, Postman, server-to-server) bypasses CORS entirely. So don't rely on CORS for auth. It's a UX enabler, not an access control gate. Use real auth middleware for security.
In production, never expose origins you don't explicitly trust. That includes * with credentials—we killed that myth in another section.
The CORSMiddleware Constructor: Tuning Origin, Method, and Header Whitelists
Eight parameters. Each one a footgun if you set it wrong. Let's skip the obvious ones and hit the killers.
allow_origins: A list of exact origins, or [""] for any. But [""] breaks with credentials. Prod tip: use allow_origin_regex for subdomain patterns like https://.*\.myapp\.com. Keeps the list tight.
allow_methods: Defaults to ["GET"]. If your frontend sends POST or DELETE, add them explicitly or use ["*"] for simplicity. But lazy wildcards leak attack surface—only allow methods your API actually exposes.
allow_headers: Same deal. If you send custom headers like X-Request-ID, list them. Using ["*"] is fine for open APIs, but for enterprise, whitelist.
expose_headers: The forgotten one. By default, browsers only expose a handful of response headers to JS. If your frontend needs X-Total-Count or Content-Disposition, add them here or JS gets null.
Set them once, test with curl and a browser, and don't touch again.
access-control-allow-origin header in the preflight (OPTIONS) response. Missing it? Your frontend dies silently.* with credentials.The Middleware That Silently Dropped All Requests
call_next was being awaited correctly.call_next and then returning a cached response object without awaiting the actual response. call_next was invoked but its result was never assigned—the middleware returned a placeholder response early.await call_next(request) first, then modify the returned response. Added explicit error logging to verify that call_next completes. Deployed additional integration tests that verify middleware pass-through.- Never modify the response before calling
call_nextunless you intend to short-circuit the request. - Always log a trace message after
call_nextreturns to confirm execution. - Add a health endpoint that bypasses all custom middleware to detect this class of failures.
- Use
mypyorpyrightto catch missingawaiton async functions at development time.
call_next. Verify that middleware doesn't accidentally consume the body stream without re-wrapping it.call_next is awaited in every middleware. Use timeout middleware to detect blocking middleware.allow_origins list. Ensure wildcard * is not paired with allow_credentials=True. Verify that CORS headers are present on error responses.asyncio.wait_for to fail fast if any middleware blocks.call_next on request.state or request.headers. Be aware that request.headers is immutable in some contexts.curl -v http://localhost:8000/health --connect-timeout 5docker logs $(docker ps -q -f name=fastapi) 2>&1 | grep -i 'call_next'@app.get('/health') before any middleware definition.Key takeaways
allow_origins=['*'] if allow_credentials is set to True due to W3C security specs.request.state to pass variables (like user IDs or trace IDs) from middleware into your endpoint logic.async def without utilizing threads, as it will block the entire event loop for all users.Common mistakes to avoid
4 patternsUsing allow_origins=['*'] with allow_credentials=True
Access-Control-Allow-Origin header, or returns * and browser rejects it.['https://example.com']. For dynamic origins, implement a middleware that checks the Origin header against a whitelist and sets it per request.Forgetting to `await call_next(request)` in an async middleware
await call_next(request) to a variable. Use a linter rule (like flake8-async) to detect missing await on async functions. Add a timeout to the outermost middleware to fail fast.Performing synchronous blocking I/O inside `async def` middleware
time.sleep(), requests.get(), file I/O) into a background thread using asyncio.to_thread() or loop.run_in_executor(). Or use non-blocking alternatives (e.g., httpx.AsyncClient).Modifying `request.state` in one middleware but reading it in another without ordering guarantee
Interview Questions on This Topic
Describe the execution flow of multiple middlewares in FastAPI. If Middleware A is added before Middleware B, which one sees the Response object first?
python
app.add_middleware(A)
app.add_middleware(B)
# Request: B -> A -> route
# Response: route -> A -> B
``
So B sees the response first.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
That's Python Libraries. Mark it forged?
7 min read · try the examples if you haven't