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.
- 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.
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.
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.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.@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
That's Python Libraries. Mark it forged?
3 min read · try the examples if you haven't