FastAPI Middleware — Logging, CORS and Custom Middleware
- Middleware added last runs first for requests, and last for responses (LIFO order).
- Global Context: Middleware is protocol-agnostic regarding specific routes; it sees all traffic including 404s and health checks.
- The 'No Wildcard' Rule: You cannot use
allow_origins=['*']ifallow_credentialsis set toTruedue to W3C security specs.
- 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.
API responds with 504 Gateway Timeout after middleware change
curl -v http://localhost:8000/health --connect-timeout 5docker logs $(docker ps -q -f name=fastapi) 2>&1 | grep -i 'call_next'CORS preflight (OPTIONS) request fails
curl -X OPTIONS -I https://api.example.com/datadocker compose logs | grep -i 'cors'Custom trace ID not appearing in application logs
curl -v http://localhost:8000/test | jq '.headers'grep 'trace_id' app/logs/*.logMiddleware throws AttributeError: 'Request' object has no attribute 'state'
python -c "from fastapi import Request; r = Request(scope={'type':'http'}); print(hasattr(r,'state'))"grep 'request.state' app/middleware/*.py -nProduction Incident
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.call_next unless you intend to short-circuit the request.Always log a trace message after call_next returns to confirm execution.Add a health endpoint that bypasses all custom middleware to detect this class of failures.Use mypy or pyright to catch missing await on async functions at development time.Production Debug GuideSymptom → Action for Production Issues
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.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.
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() # Configure the 'Onion' layers # CORSMiddleware should generally be added early in the stack app.add_middleware( CORSMiddleware, # List specific trusted origins for production allow_origins=[ 'https://api.thecodeforge.io', 'https://dashboard.thecodeforge.io', 'http://localhost:3000' ], # Required if your frontend sends cookies or Authorization headers allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allow_headers=['Authorization', 'X-Forge-Trace-ID', 'Content-Type'], ) # Note: If allow_credentials is True, allow_origins cannot be ['*']
Access-Control-Allow-Origin: https://dashboard.thecodeforge.io
- If you set
allow_credentials=Trueandallow_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:3000explicitly; 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.
from fastapi import FastAPI, Request import time import uuid import logging app = FastAPI() logger = logging.getLogger("thecodeforge.access") @app.middleware('http') async def add_process_time_header(request: Request, call_next): # 1. Logic BEFORE the route (Request Phase) start_time = time.perf_counter() request_id = str(uuid.uuid4()) # Inject trace ID into request state for downstream access request.state.trace_id = request_id # 2. Hand off to the next middleware or route handler response = await call_next(request) # 3. Logic AFTER the route (Response Phase) process_time = time.perf_counter() - start_time # Log the performance metric logger.info(f"RID: {request_id} | Path: {request.url.path} | Time: {process_time:.4f}s") # Standardize our response headers response.headers['X-Forge-Process-Time'] = str(process_time) response.headers['X-Forge-Trace-ID'] = request_id return response
- 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.
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() # Execution order (request direction): # 1. Security headers middleware (added last) # 2. CORSMiddleware # 3. Logging middleware (added first) # 4. Route handler # Response order is reverse app.add_middleware( CORSMiddleware, allow_origins=["https://example.com"], allow_credentials=True, allow_headers=["*"], ) @app.middleware("http") async def security_headers(request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" return response @app.middleware("http") async def logging_middleware(request, call_next): print(f"Request: {request.method} {request.url}") response = await call_next(request) print(f"Response status: {response.status_code}") return response
- 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.
from fastapi import FastAPI, Request, Response import logging app = FastAPI() logger = logging.getLogger("thecodeforge.security") BLOCKED_IPS = {"10.0.0.99", "192.168.1.200"} @app.middleware("http") async def ip_blocker(request: Request, call_next): client_ip = request.client.host if request.client else "unknown" if client_ip in BLOCKED_IPS: logger.warning(f"Blocked IP: {client_ip}") return Response( content='{"detail": "Forbidden"}', status_code=403, media_type="application/json" ) # If not blocked, continue response = await call_next(request) return response
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.
from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse import logging import traceback thecodeforge_logger = logging.getLogger("thecodeforge.error") app = FastAPI() @app.middleware("http") async def catch_exceptions_middleware(request: Request, call_next): try: response = await call_next(request) return response except Exception as exc: # Log the full traceback internally thecodeforge_logger.error( f"Unhandled exception on {request.method} {request.url.path}: {traceback.format_exc()}" ) # Return a generic error to the client return JSONResponse( status_code=500, content={"detail": "Internal server error. Please try again later.", "trace_id": request.state.trace_id if hasattr(request, 'state') else None} )
@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.
import pytest from fastapi.testclient import TestClient from io.thecodeforge.main import app # assuming your FastAPI app is here client = TestClient(app) class TestCORS: def test_cors_headers_present(self): response = client.get("/", headers={"Origin": "https://dashboard.thecodeforge.io"}) assert response.headers.get("access-control-allow-origin") == "https://dashboard.thecodeforge.io" assert response.headers.get("access-control-allow-credentials") == "true" def test_cors_rejects_wrong_origin(self): response = client.get("/", headers={"Origin": "https://evil.com"}) # No CORS headers means browser blocks it assert "access-control-allow-origin" not in response.headers class TestShortCircuit: def test_blocked_ip_returns_403(self): # Simulate an IP - this might not work in TestClient because request.client is None # We need to mock request.client with patch("fastapi.Request.client", new_callable=PropertyMock) as mock_client: mock_client.return_value = Mock(host="10.0.0.99") response = client.get("/") assert response.status_code == 403 assert response.json() == {"detail": "Forbidden"}
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.| Concern | Middleware | Dependency (Depends()) |
|---|---|---|
| Execution timing | Before route matching | After route matching |
| Access to route parameters | No | Yes |
| Can short-circuit request | Yes (return Response) | No (raises exception) |
| Modify response headers | Yes | Limited (via response) but harder |
| Performance overhead per request | Always runs for all routes | Only runs when used in route |
| Example use cases | CORS, logging, IP blocking, compression | Auth, pagination, DB sessions |
🎯 Key Takeaways
- Middleware added last runs first for requests, and last for responses (LIFO order).
- Global Context: Middleware is protocol-agnostic regarding specific routes; it sees all traffic including 404s and health checks.
- The 'No Wildcard' Rule: You cannot use
allow_origins=['*']ifallow_credentialsis set toTruedue to W3C security specs. - State Sharing: Use
request.stateto pass variables (like user IDs or trace IDs) from middleware into your endpoint logic. - Avoid Blocking: Never perform heavy synchronous I/O inside a middleware's
async defwithout utilizing threads, as it will block the entire event loop for all users. - Short-circuiting is for early exits, but ensure mandatory middlewares run first or you'll miss CORS headers and logs.
- Test middleware with both TestClient and live e2e tests: differences exist between them.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QDescribe the execution flow of multiple middlewares in FastAPI. If Middleware A is added before Middleware B, which one sees the Response object first?SeniorReveal
- QWhy is it a security risk to allow all origins (
'*') in a production API that uses JWT cookies for authentication?SeniorReveal - QHow does the ASGI 'scope' differ from the FastAPI 'Request' object, and how would you access it inside a low-level middleware?Mid-levelReveal
- QExplain the 'Short-Circuiting' behavior: How can a middleware return a response without ever calling the
call_nextfunction?Mid-levelReveal - QScenario: You need to implement IP-based rate limiting. Would you do this in a FastAPI middleware or a Dependency? Justify your choice based on performance and 'Route Matching' logic.SeniorReveal
Frequently Asked Questions
What is the difference between middleware and a Depends() dependency?
Execution timing and scope are the key differences. Middleware executes at the 'Gateway' level before FastAPI even figures out which route should handle the request. This makes it perfect for logging and CORS. Dependencies (Depends()) execute after the route is matched but before the business logic runs. Use dependencies for logic that requires access to route parameters or endpoint-specific data.
Why am I getting CORS errors even after adding CORSMiddleware?
This usually happens due to one of three reasons: 1) allow_credentials=True with wildcard * for origins. 2) Your frontend is sending a custom header (e.g., X-Requested-With) that isn't included in your allow_headers list. 3) Middleware order: If you have another middleware that returns a response (like an Auth check) before the CORSMiddleware, the CORS headers won't be attached to the error response.
Is there a limit to how many middlewares I can add?
While there is no hard limit, each middleware layer adds a small amount of overhead to the request/response cycle. If you have 20+ middlewares, you may see an increase in latency. For complex transformations, consider moving logic into a background task or an external API gateway like Nginx or Kong.
Can I use FastAPI middleware with WebSocket endpoints?
Yes, but only with @app.middleware('http'). For WebSockets, you need to use a lower-level ASGI middleware that handles both HTTP and WebSocket scopes. FastAPI's built-in middleware only applies to HTTP connections. For WebSocket authentication or logging, implement a custom ASGI middleware that inspects the scope['type'] and applies logic accordingly.
How do I pass data from middleware to route handlers safely?
Use request.state to attach arbitrary data (e.g., request.state.user after authentication). This is a per-request dictionary that is cleared after the response. However, be careful: if you mutate request.state in a middleware that short-circuits, downstream middleware won't see it. Also, ensure you set state before the route handler runs—so in the outermost middleware (added last) during the request phase.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.