FastAPI – Middleware, Ordering and CORS

October 8, 202613 min readUpdated 8/21/2026

Middleware is for the things every request needs regardless of what it does — a correlation id, a timing header, one access log line, CORS headers. It is also where an ordering rule that reads backwards produced a genuine bug in this codebase, one that made every server error look like a network failure to the browser.

What middleware actually is

An ASGI application is a callable over (scope, receive, send). Middleware is an ASGI application that wraps another one — so your app is a stack of layers around a router:

ServerErrorMiddleware          catches whatever escapes everything else
  └─ your middleware           in the order registered (outermost = added LAST)
     └─ ExceptionMiddleware    runs your @app.exception_handler(...) handlers
        └─ router
           └─ dependencies
              └─ your function

Every layer sees the request on the way in and the response on the way out, and any of them can short-circuit — which is exactly what CORS does to a preflight.

Writing one

The convenient base class gives you a request and a call_next:

class RequestContextMiddleware(BaseHTTPMiddleware):
    """Stamps an id on the request, times it, logs one line, echoes the id back.

    The id is taken from an inbound `X-Request-ID` when there is one. That is what makes it useful
    beyond this process: a reverse proxy or an upstream service that already assigned an id gets
    the SAME id in our logs, so one identifier follows a request across the whole system. We only
    invent one when nobody else has.
    """
        request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex[:16]

Accepting an inbound id is what makes the whole thing useful. Generate one unconditionally and it correlates lines within this process; honour the one your proxy or upstream service already assigned and a single identifier follows a request across every system it touches.

Registering it is one line, and the type is the argument — not an instance:

app.add_middleware(RequestContextMiddleware)

Carrying the id down the stack

The id has to be reachable from a repository four layers down without being threaded through every signature. That is a ContextVar:

request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")

Not a global and not a thread-local, and the reasoning is worth keeping:

# A module-level global is shared by every concurrent request, so request B overwrites A's id
# mid-flight. A thread-local is nearly right but breaks on the async half of the app: many
# coroutines share one thread, so they would share one "thread-local" value.
#
# A ContextVar is per-execution-context. asyncio copies the context into each task, and
# `run_in_threadpool` (which is how FastAPI runs every `def` route) copies it into the worker
# thread. So it holds for both sync and async routes, which is the only reason one mechanism
# covers this whole app.

The direction it propagates is the part to internalise:

        # ⚠️ Set BEFORE call_next, and this direction is the only one that works.
        #
        # BaseHTTPMiddleware runs the downstream app in a child anyio task. A child task inherits
        # a COPY of the context at creation, so a value set here is visible all the way down —
        # routes, services, repositories. The reverse is not true: anything the downstream app
        # sets is written into its own copy and is invisible here after call_next returns.
        #
        # So: seed context on the way in, never expect to read it back on the way out.
        token = request_id_ctx.set(request_id)

Seed on the way in; never expect to read something back on the way out. A middleware that tries to collect a value the route set will get the default, silently.

⚠️ Ordering reads backwards

add_middleware inserts at the front of the list, so the last one registered is the outermost layer — the reverse of how the calls read down the page.

StayHub registers these two inner-first, and the comment explains why that is not arbitrary:

# ⚠️ ORDER IS LOAD-BEARING, and it reads backwards.
#
# `add_middleware` INSERTS AT THE FRONT of the list, so the LAST call here is the OUTERMOST
# layer. These two are therefore added inner-first: RequestContextMiddleware, then CORS around it.
#
# CORS MUST be outside. RequestContextMiddleware turns an unhandled exception into a 500 response
# (see the ⚠️ in core/middleware.py); CORS only adds `Access-Control-Allow-Origin` to responses
# that pass back through it. Swap these two calls and every 500 reaches the browser without CORS
# headers, the browser reports a CORS failure instead, and the frontend's error parser never sees
# the body. Verified both ways on 2026-08-21.
app.add_middleware(RequestContextMiddleware)

The bug this describes

It is worth walking through, because nothing in the source looks wrong.

Lesson 8 covered the cause: a handler registered for bare Exception becomes ServerErrorMiddleware's handler, which sits outside every user middleware. So a 500 built there has skipped all of them on the way back. Measured before the fix:

unhandled 500 ->  X-Request-ID absent   Access-Control-Allow-Origin absent
handled   404 ->  X-Request-ID present  Access-Control-Allow-Origin present

A browser shown a 500 with no Access-Control-Allow-Origin reports a CORS failure and never exposes the body. So the React app's error handling never saw the message the API had written, and every server error presented as a network problem.

The fix has two halves. The middleware catches the exception and returns a response rather than re-raising, so it never reaches the outer layer:

            response = unhandled_response()
            response.headers[REQUEST_ID_HEADER] = request_id
            response.headers["X-Response-Time-Ms"] = f"{elapsed_ms:.1f}"
            request_id_ctx.reset(token)
            return response

And CORS is registered outside it, so it sees that response as an ordinary one and adds its headers. Get the order wrong and everything still works, right up until something throws.

CORS, and the rule that catches everyone

CORS is the browser refusing to let JavaScript on one origin read a response from another unless that response says it is allowed. It is enforced by the browser and nowhere else — curl ignores it entirely, which is why an endpoint can work perfectly from a terminal and fail from a page.

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

The trap is the interaction between two of those arguments:

# ⚠️ allow_credentials=True forbids `allow_origins=["*"]` — the browser rejects a wildcard when
# credentials are involved, and the failure shows up as a CORS error with no useful detail. The
# origins are therefore named explicitly in config.py.

The specification forbids the combination, and the browser enforces it. The failure message says nothing about which of your settings caused it, so the usual response — making the CORS config more permissive — makes it worse. Name your origins.

    cors_origins: list[str] = [
        "http://localhost:5174",
        "http://localhost:5175",
    ]

Those are exact-match strings. http://localhost:5174 and http://127.0.0.1:5174 are different origins, as are the same host on http and https, or on a different port. Most "CORS is broken" reports are one of those three.

Preflight

For anything beyond a simple form post, the browser first sends an OPTIONS request asking permission:

curl -s -o /dev/null -w '%{http_code}\n' -X OPTIONS \
  -H 'Origin: http://localhost:5174' \
  -H 'Access-Control-Request-Method: POST' \
  localhost:8000/api/v1/auth/login
200

CORSMiddleware answers that itself — the request never reaches your route, which is why a preflight to a nonexistent path can still succeed. A Content-Type: application/json header is enough to trigger one, so every JSON API pays this on non-GET requests. max_age lets the browser cache the answer and skip it.

One more, for a header your own code sets: a browser can only read Cache-Control, Content-Type, Expires, Last-Modified and Pragma unless you say otherwise. To let a frontend read the request id, it has to be exposed:

# added to the CORSMiddleware call above:
    expose_headers=["X-Request-ID"],

Without it the header arrives on the response and JavaScript simply cannot read it — which looks like the server not sending it, and sends you debugging the wrong side.

The access log

        if request.url.path not in QUIET_PATHS:
            # A 5xx is our fault and a 4xx usually is not, so they log at different levels — that
            # alone makes "alert on ERROR" a usable rule.
            level = (
                logging.ERROR
                if response.status_code >= 500
                else logging.WARNING
                if response.status_code >= 400
                else logging.INFO
            )

Splitting by class is what makes "alert on ERROR" mean something. If a 404 and a 500 log identically, either your alerts fire constantly or they are turned off.

QUIET_PATHS = frozenset({"/health", "/docs", "/openapi.json", "/redoc"})

/health is polled by the orchestrator every few seconds forever. Logging it buries every request anyone cares about under thousands of identical lines.

Middleware or dependency?

They overlap, and picking wrong is a common source of awkward code.

MiddlewareDependency
Applies toevery request, routes included or notroutes that ask for it
Sees the responseyesno
Can inject a valuenoyes
In the OpenAPI schemanoyes
Knows which route matchednot reliablyyes

Use middleware for cross-cutting concerns that touch the response or must cover everything: request ids, timing, access logs, CORS, compression, security headers.

Use a dependency for anything route-specific or anything that injects: auth, authorization, pagination, a database session. Authentication as middleware is a recurring mistake — it cannot inject the user, it cannot easily know whether this route is public, and it does not appear in the documentation.

Middleware worth having

Beyond request ids and CORS, a short list earns its place in most services.

Security headers, which are free and stop a category of problem:

class SecurityHeadersMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        response = await call_next(request)
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "no-referrer"
        return response

nosniff stops a browser second-guessing your Content-Type — which is what turns an uploaded file served as text/plain into executed HTML. X-Frame-Options prevents clickjacking. For a JSON API those two plus Strict-Transport-Security at the proxy cover most of it; a Content-Security-Policy matters more for pages than for APIs.

Compression, which ships with Starlette:

from fastapi.middleware.gzip import GZipMiddleware

app.add_middleware(GZipMiddleware, minimum_size=1000)

JSON compresses extremely well — a page of listings often drops by 80%. The minimum_size guard matters: compressing a 40-byte response costs CPU and makes it bigger.

Rate limiting is the one people expect to be middleware and usually should not be. A global limit is rarely what you want — login needs a strict per-IP limit while a health check needs none — and middleware does not reliably know which route matched. A dependency on the routes that need it is the better shape. And the counter has to live in Redis, not in process memory, because workers share nothing.

Ordering, in practice

Given that the last registration is outermost, a sensible full stack reads bottom-up:

app.add_middleware(RequestContextMiddleware)     # innermost of the four
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(CORSMiddleware, ...)          # outermost

The reasoning at each level:

  • CORS outermost, so every response — including the 500 that RequestContextMiddleware builds — passes back through it.
  • Compression above the request context, so the timing recorded is the work, not the gzip.
  • Security headers anywhere above the app, since they only add.
  • Request context innermost, so its ContextVar is set before anything downstream logs.

One asymmetry to keep in mind: on the way in the outermost runs first, and on the way out it runs last. A middleware that modifies the response body must sit outside anything that reads the response body, or it will be reading something that has since changed.

The cost of BaseHTTPMiddleware

BaseHTTPMiddleware is convenient and not free. It wraps the downstream app in an anyio task group and bridges the ASGI message stream, which adds overhead to every request and is the reason context does not propagate back up.

For most applications it is irrelevant — a fraction of a millisecond against a database query. When it matters, or when you need to see individual ASGI messages, write pure ASGI middleware:

class TimingMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        started = time.perf_counter()

        async def send_wrapper(message):
            if message["type"] == "http.response.start":
                elapsed = (time.perf_counter() - started) * 1000
                headers = MutableHeaders(scope=message)
                headers.append("X-Response-Time-Ms", f"{elapsed:.1f}")
            await send(message)

        await self.app(scope, receive, send_wrapper)

More code, no task group, and the scope["type"] != "http" guard is mandatory — lifespan and websocket events come through the same callable, and forgetting it breaks startup in a way that is hard to trace.

CORS problems, in order of likelihood

Nearly every report resolves to one of six things, and the browser's message is unhelpful for all of them.

  1. The origin is not an exact match. http://localhost:5174, http://127.0.0.1:5174 and https://localhost:5174 are three different origins.
  2. Credentials plus a wildcard. Forbidden by the specification; name the origins.
  3. The response is a 500 built outside the CORS layer. The bug this lesson is about — and it presents as CORS even though CORS is configured correctly.
  4. A custom header the frontend sends is not in allow_headers. The preflight refuses and the real request never happens.
  5. A response header the frontend reads is not in expose_headers. The request succeeds and JavaScript cannot see the header.
  6. A proxy strips the headers or answers OPTIONS itself before the application sees it.

The diagnostic that separates them takes ten seconds: if curl works and the browser does not, it is CORS. If curl also fails, it is not — CORS is enforced by browsers and nothing else. Half the time spent on "CORS errors" is spent on requests that were failing for an entirely different reason.

The preflight is worth checking directly, since it is a separate request with its own outcome:

curl -i -X OPTIONS localhost:8000/api/v1/bookings \
  -H 'Origin: http://localhost:5174' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type'

The response's Access-Control-Allow-* headers tell you exactly what the browser was told, which is more informative than anything the browser will show you.

The one that catches people in production

CORS configuration is environment-specific and almost always hardcoded for development:

    # The two Vite dev servers. Anything else fails CORS, and the symptom in the browser is a
    # blank page rather than an error anyone would recognise.
    cors_origins: list[str] = [
        "http://localhost:5174",
        "http://localhost:5175",
    ]

Deploying with those defaults means the production frontend — on a real domain, over HTTPS — is not in the list, and every request from it fails. Everything else works: health checks pass, curl works, the deployment looks successful.

Because it is a setting, the fix is an environment variable rather than a release. It is worth adding to a deploy checklist precisely because nothing in the application will complain.

Things that look like middleware and are not

Three jobs people reach for middleware to do, where something else fits better.

Catching exceptions. That is what exception handlers are for — with the one caveat this lesson exists to explain, which is that a bare Exception handler ends up outside your middleware and a 500 therefore needs catching inside it.

Modifying the response body. Possible and awkward: the body has already been serialised, so you are parsing JSON to re-emit it, and streaming responses cannot be buffered without breaking them. If every response needs an envelope, put it in the models.

Per-route configuration. Middleware runs before routing resolves, so it does not reliably know which endpoint will handle the request. Anything conditional on the route belongs in a dependency.

Middleware and startup are different things

A small confusion worth clearing: middleware wraps requests, and lifespan wraps the application. Work that should happen once at boot — opening a connection pool, checking a dependency, loading a model — belongs in the lifespan, not in a middleware guarded by a flag.

The two combine cleanly: create the shared thing in the lifespan, and let middleware or a dependency hand it out. What you should not do is lazily initialise inside a middleware, because several concurrent first requests will all find it uninitialised and all try.

Testing it

Middleware bugs live entirely in configuration, so nothing below TestClient can see them:

    def test_a_500_still_carries_cors_headers(self, client, probe_routes):
        """⚠️ THE regression test. Reordering the two add_middleware calls in main.py breaks this
        and nothing else in the suite."""
        r = client.get("/__test/boom", headers={"Origin": ORIGIN})
        assert r.status_code == 500
        assert r.headers.get("access-control-allow-origin") == ORIGIN

That claim was checked by reintroducing the bug: swapping the two registrations fails this test and nothing else. A regression test nobody has watched fail is a guess.

Two details make such tests possible. raise_server_exceptions=False makes TestClient behave like a real server and return the 500 rather than re-raising into the test. And the probe routes are registered on the real app, so they travel the real stack — a second app assembled by hand would be testing a copy of the configuration rather than the configuration.

Next: testing the whole stack — including the class of bug this lesson produced, which is invisible to every test below the HTTP layer.