Senior 3 min · March 05, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 wrap call_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.

io/thecodeforge/middleware/security.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 ['*']
Output
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://dashboard.thecodeforge.io
Wildcard + Credentials = Error
If you set 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.
Production Insight
CORS middleware is order-sensitive. If an earlier middleware (like an auth check) returns an error without the CORS headers, the browser blocks the error response.
Solution: add CORSMiddleware first in the stack (last in the list of add_middleware calls).
Rule: the first middleware to handle requests should set CORS headers on every response.
Key Takeaway
CORS is not about allowing everything. It's about telling the browser which sites are trusted.
Whitelist explicit origins. Never pair wildcard with credentials.
If you see a CORS error in production, check middleware order first.
When to use which origin setting
IfSingle frontend domain, needs cookies/auth headers
UseUse allow_origins=[specific_domain] with allow_credentials=True
IfPublic API, no cookies
UseWildcard ['*'] is acceptable, but still prefer explicit list for logging
IfMultiple domains, need credentials
UseImplement dynamic origin per request using a custom middleware that checks origin against a whitelist

Custom 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.

io/thecodeforge/middleware/logging.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
Output
→ RID: 550e8400-e29b-41d4-a716-446655440000 | Path: /api/v1/users | Time: 0.0042s
The Onion Execution Model
  • The middleware added last wraps the outermost layer. It sees the request first and the response last.
  • Each middleware calls call_next to 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.
Production Insight
If a middleware performs a blocking I/O operation (e.g., time.sleep(0.5)) in the async path, the entire event loop stalls.
Solution: use asyncio.to_thread for blocking calls, or shift heavy work to a background task.
Rule: keep middleware logic lightweight and non-blocking.
Key Takeaway
Measure performance, but don't degrade performance.
Use time.perf_counter() not time.time() for nanosecond precision.
Never block the event loop in middleware. If it blocks, isolate it.

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 app.add_middleware() 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.

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.

io/thecodeforge/middleware/order.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
Stack vs Queue for Request/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).
Production Insight
A common production mistake is adding a security header middleware after CORS, assuming the headers will be visible to the browser. But CORS runs after on the response, potentially overwriting headers.
Fix: add security header middleware after CORS (i.e., add it before CORS in code) so it runs after CORS on the response.
Rule: trace your middleware order: print a log entry on entry and exit during investigation.
Key Takeaway
Middleware order is not just for execution—it's for header visibility too.
Add CORS last in code so it wraps the entire response.
Log entry/exit of each middleware when debugging order issues.

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.

io/thecodeforge/middleware/ip_blocker.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
Short-Circuiting and CORS
If your IP blocker short-circuits before CORSMiddleware (because it was added after CORSMiddleware in code), the block response will not include CORS headers. The browser sees a CORS error, not the 403. Solution: add the IP blocker middleware after CORSMiddleware (i.e., app.add_middleware(IPBlockerMiddleware, ...) after app.add_middleware(CORSMiddleware, ...)) so that CORSMiddleware wraps the IP blocker and adds headers to its responses.
Production Insight
Short-circuiting middlewares are ideal for early exits, but they break the onion. Every short-circuit means downstream middlewares never execute.
Impact: metrics middleware won't log the blocked request, trace IDs won't be injected, etc.
Mitigation: move metrics to a middleware that runs before IP blocking (i.e., add it after in code).
Rule: design your middleware stack so that the outermost (first added) layers are those that must always execute.
Key Takeaway
Short-circuiting is powerful but dangerous.
Ensure mandatory middlewares (CORS, logging) run before short-circuiting middlewares.
Test short-circuited paths for missing headers.

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.

io/thecodeforge/middleware/error_handler.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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}
        )
Exception Handling in Middleware vs. FastAPI Exception Handlers
FastAPI's @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.
Production Insight
If you catch all exceptions in middleware and return a 500, you may lose HTTPException errors (e.g., 404, 422) that FastAPI would normally handle gracefully.
Solution: re-raise HTTPException after logging, or check the exception type.
Rule: let HTTPException pass through to FastAPI's handler; catch only Exception for truly unexpected errors.
Key Takeaway
Error handling middleware is a safety net, not a replacement for specific exception handlers.
Always log the full traceback. Always include a trace ID in the error response.
Don't swallow HTTPException—let FastAPI handle it correctly.

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.

io/thecodeforge/tests/test_middleware.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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"}
Mocking request.client for IP Tests
The 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.
Production Insight
Integration tests with TestClient catch most middleware issues, but not all. Production differences (middleware order when multiple services, ASGI server version) can cause subtle failures.
Solution: also run smoke tests against a real containerized deployment.
Rule: test middleware in isolation and in the full stack.
Key Takeaway
Middleware must be tested, especially short-circuit and header logic.
Mock request.client carefully.
Use both TestClient and live e2e tests.
● Production incidentPOST-MORTEMseverity: high

The Middleware That Silently Dropped All Requests

Symptom
After a routine middleware update, every endpoint returned 504 Gateway Timeout. No errors in the application logs, but the middleware logs showed requests entering but never leaving.
Assumption
The new middleware was non-blocking and would naturally pass through to the route handler. The team assumed call_next was being awaited correctly.
Root cause
The middleware function was modifying the response object before calling 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.
Fix
Restructured the middleware to always 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.
Key lesson
  • Never modify the response before calling 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 Issues5 entries
Symptom · 01
Endpoint returns empty response (no body)
Fix
Check if a middleware modified the response body after call_next. Verify that middleware doesn't accidentally consume the body stream without re-wrapping it.
Symptom · 02
All routes return 503 Service Unavailable
Fix
Verify call_next is awaited in every middleware. Use timeout middleware to detect blocking middleware.
Symptom · 03
CORS errors only in production (works locally)
Fix
Check allow_origins list. Ensure wildcard * is not paired with allow_credentials=True. Verify that CORS headers are present on error responses.
Symptom · 04
Request hangs indefinitely
Fix
Add a timeout to the outermost middleware. Use asyncio.wait_for to fail fast if any middleware blocks.
Symptom · 05
Custom header not reaching the route handler
Fix
Confirm the header is set before call_next on request.state or request.headers. Be aware that request.headers is immutable in some contexts.
★ FastAPI Middleware Quick Debug Cheat SheetCommands and immediate actions for common middleware problems in production.
API responds with 504 Gateway Timeout after middleware change
Immediate action
Pause the middleware change. Restore the previous version. Then reproduce locally.
Commands
curl -v http://localhost:8000/health --connect-timeout 5
docker logs $(docker ps -q -f name=fastapi) 2>&1 | grep -i 'call_next'
Fix now
Add a health check endpoint that bypasses middleware: @app.get('/health') before any middleware definition.
CORS preflight (OPTIONS) request fails+
Immediate action
Check response headers. Look for `Access-Control-Allow-Origin`. Use a simple GET request to isolate.
Commands
curl -X OPTIONS -I https://api.example.com/data
docker compose logs | grep -i 'cors'
Fix now
Ensure allow_origins includes the exact origin (not '*') when allow_credentials=True. Add missing headers to allow_headers.
Custom trace ID not appearing in application logs+
Immediate action
Check if `request.state.trace_id` is set before calling `call_next`. Verify the downstream code reads `request.state.trace_id`.
Commands
curl -v http://localhost:8000/test | jq '.headers'
grep 'trace_id' app/logs/*.log
Fix now
Move trace ID injection to a middleware that runs first (added last) and log it immediately after set. Use a middleware-specific logger.
Middleware throws AttributeError: 'Request' object has no attribute 'state'+
Immediate action
Do not rely on `request.state` in ASGI middleware level. Use `request.scope` instead for middleware-scoped data.
Commands
python -c "from fastapi import Request; r = Request(scope={'type':'http'}); print(hasattr(r,'state'))"
grep 'request.state' app/middleware/*.py -n
Fix now
Set request.state.trace_id in the middleware that runs first, before passing to others. Ensure all middlewares that read it run after.
Middleware vs Dependency for Cross-Cutting Concerns
ConcernMiddlewareDependency (Depends())
Execution timingBefore route matchingAfter route matching
Access to route parametersNoYes
Can short-circuit requestYes (return Response)No (raises exception)
Modify response headersYesLimited (via response) but harder
Performance overhead per requestAlways runs for all routesOnly runs when used in route
Example use casesCORS, logging, IP blocking, compressionAuth, pagination, DB sessions

Key takeaways

1
Middleware added last runs first for requests, and last for responses (LIFO order).
2
Global Context
Middleware is protocol-agnostic regarding specific routes; it sees all traffic including 404s and health checks.
3
The 'No Wildcard' Rule
You cannot use allow_origins=['*'] if allow_credentials is set to True due to W3C security specs.
4
State Sharing
Use request.state to pass variables (like user IDs or trace IDs) from middleware into your endpoint logic.
5
Avoid Blocking
Never perform heavy synchronous I/O inside a middleware's async def without utilizing threads, as it will block the entire event loop for all users.
6
Short-circuiting is for early exits, but ensure mandatory middlewares run first or you'll miss CORS headers and logs.
7
Test middleware with both TestClient and live e2e tests
differences exist between them.

Common mistakes to avoid

4 patterns
×

Using allow_origins=['*'] with allow_credentials=True

Symptom
Browser shows CORS error for requests that include cookies or Authorization headers. The preflight OPTIONS request returns 200 but missing Access-Control-Allow-Origin header, or returns * and browser rejects it.
Fix
Set explicit origins like ['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

Symptom
The middleware appears to work, but requests hang indefinitely or return with incorrect response body (often empty or incomplete). No errors are raised because the coroutine is never awaited.
Fix
Always assign 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

Symptom
Under load, the API becomes unresponsive. Only one request at a time can pass through the middleware. Latency increases linearly with concurrent requests.
Fix
Move blocking operations (e.g., 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

Symptom
AttributeError: 'Request' object has no attribute 'state' in some routes, or the state is not present when expected. This happens when the middleware that sets the state runs after the one that reads it.
Fix
Define a clear middleware order: set state in the outermost middleware (added last) so it runs first on the request. Then all inner middleware and route handlers can access it. Document the order in a comment.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Describe the execution flow of multiple middlewares in FastAPI. If Middl...
Q02SENIOR
Why is it a security risk to allow all origins (`'*'`) in a production A...
Q03SENIOR
How does the ASGI 'scope' differ from the FastAPI 'Request' object, and ...
Q04SENIOR
Explain the 'Short-Circuiting' behavior: How can a middleware return a r...
Q05SENIOR
Scenario: You need to implement IP-based rate limiting. Would you do thi...
Q01 of 05SENIOR

Describe the execution flow of multiple middlewares in FastAPI. If Middleware A is added before Middleware B, which one sees the Response object first?

ANSWER
In FastAPI, middleware execution follows a LIFO (Last-In-First-Out) order for the request phase and FIFO for the response phase. Middleware A added before B means B is the outermost layer (because it was added last). So the request hits B first, then A, then the route handler. On the response, the order is reversed: the response passes through A first, then B. Therefore, B sees the response first (before A). This is the onion model. Code example: ``python app.add_middleware(A) app.add_middleware(B) # Request: B -> A -> route # Response: route -> A -> B `` So B sees the response first.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between middleware and a Depends() dependency?
02
Why am I getting CORS errors even after adding CORSMiddleware?
03
Is there a limit to how many middlewares I can add?
04
Can I use FastAPI middleware with WebSocket endpoints?
05
How do I pass data from middleware to route handlers safely?
🔥

That's Python Libraries. Mark it forged?

3 min read · try the examples if you haven't

Previous
FastAPI File Uploads and Form Data
45 / 51 · Python Libraries
Next
FastAPI Testing with pytest and TestClient