Middleware, CORS & Proxies

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7 and Uvicorn 0.54, checked on 2026-10-02. Every example on the page was executed on Python 3.13 with TestClient; the proxy examples were also run against real fastapi run processes and curl. Middleware is the code that wraps every request and response of the Bookshelf API without belonging to any path operation: timing, request IDs, compression, host validation, and the browser-facing CORS headers. The second half of the page covers the other thing that sits around your app in production, the reverse proxy, and the handful of settings (--forwarded-allow-ips, root_path, OpenAPI servers) that make FastAPI behave correctly behind it.

What Middleware Is

A middleware is a callable that receives a request before any path operation and the response after it. It can inspect or change either, answer without calling the application at all, or catch what the application raises. In FastAPI, which inherits the mechanism from Starlette, every middleware is an ASGI application that wraps another ASGI application. Stacking several of them produces an "onion": the request travels inward through the layers, the response travels outward through the same layers in reverse order.

flowchart TB client([Client]) subgraph SE["ServerErrorMiddleware (always outermost)"] subgraph TH["TrustedHostMiddleware (added last)"] subgraph CO["CORSMiddleware"] subgraph GZ["GZipMiddleware (added first)"] subgraph EX["ExceptionMiddleware (handlers for HTTPException, RequestValidationError, ...)"] R["Router: route match, dependencies, validation, path operation function"] end end end end end client -->|"request: inward"| SE SE -->|"response: outward"| client

The default layers are part of every Starlette application: ServerErrorMiddleware is always the outermost layer (it turns an unhandled exception into a 500 response, with a traceback in debug mode) and ExceptionMiddleware is always the innermost one before the router (it turns HTTPException and other registered exceptions into responses). Your own middleware sits between them. FastAPI inserts a few more internal layers of its own, for example one that closes uploaded files, which are an implementation detail and not an API you configure.

Two consequences follow from that position, and they explain several pitfalls later on the page:

  • Because ExceptionMiddleware is inside your middleware, an HTTPException raised by a path operation has already become a normal response by the time your middleware sees it. An exception that is not handled, a plain RuntimeError, travels outward through your middleware as an exception and is only converted to a 500 by ServerErrorMiddleware, which is outside of it.

  • Anything that must apply to every response, including that last-resort 500, has to wrap the whole application (see CORS and error responses for the CORS version of this problem).

What is not middleware: dependencies (Dependency Injection) run inside the router, per path operation, can use FastAPI’s parameter parsing, and appear in the OpenAPI document. Middleware sees only the raw ASGI request and response, runs for every route (including /docs, mounted sub-applications and 404s), and never appears in OpenAPI. Choose middleware for cross-cutting transport concerns and dependencies for anything that depends on what the endpoint declares.

@app.middleware("http")

The shortest way to write a middleware is a coroutine function decorated with @app.middleware("http"). It receives the Request and a call_next coroutine; calling call_next(request) runs the rest of the stack and returns the Response, which you can modify before returning it. The following app adds two headers: the processing time and a request ID that is taken from the client’s X-Request-ID (so it survives a chain of services) or generated.

import time
import uuid

from fastapi import FastAPI, Request, Response
from fastapi.testclient import TestClient

app = FastAPI(title="Bookshelf API")
trace: list[str] = []


@app.middleware("http")
async def add_process_time(request: Request, call_next):
    trace.append("process-time: before")
    start = time.perf_counter()
    response = await call_next(request)
    response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.6f}"
    trace.append("process-time: after")
    return response


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    trace.append("request-id: before")
    request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
    response: Response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    trace.append("request-id: after")
    return response


@app.get("/books/{book_id}")
async def read_book(book_id: int) -> dict[str, int]:
    trace.append("path operation")
    return {"book_id": book_id}


client = TestClient(app)
r = client.get("/books/1", headers={"X-Request-ID": "abc-123"})
print(r.status_code, r.json(), r.headers["x-request-id"], "x-process-time" in r.headers)
print(trace)

Running it prints the response and the order in which the pieces ran:

200 {'book_id': 1} abc-123 True
['request-id: before', 'process-time: before', 'path operation', 'process-time: after', 'request-id: after']

Things worth knowing about this style:

  • The string "http" is the only supported value; it selects HTTP requests. The decorator does not run for WebSockets (use Pure ASGI Middleware for those) and is only a shortcut for app.add_middleware(BaseHTTPMiddleware, dispatch=…​).

  • time.perf_counter() is the right clock for durations; time.time() can jump.

  • A custom header name should carry the X- prefix by convention, and a header that browser JavaScript must read has to be listed in CORS expose_headers (see CORS).

  • call_next hands you a streaming response object. Replacing its body (compressing, rewriting JSON) from this style of middleware is awkward and costs an extra copy; that is the signal to write a pure ASGI middleware.

  • If your middleware needs a database session, a user or any value that depends on the route, it probably wants to be a dependency instead.

The ordering of two decorated middlewares was visible in the output: the one declared last ran first on the way in. The next section explains why, and how it generalises.

Execution Order

Each app.add_middleware(…​) call (and each @app.middleware(…​) decorator, which calls it for you) wraps the application that exists so far. Therefore the last middleware added is the outermost layer and runs first on the request; the first one added is the innermost. On the response path the order is reversed. The attribute app.user_middleware lists the layers outermost first.

from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send

trace: list[str] = []


class Tag:
    """Pure ASGI middleware that records when it is entered and left."""

    def __init__(self, app: ASGIApp, name: str) -> None:
        self.app = app
        self.name = name

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        trace.append(f"{self.name} request")

        async def send_wrapper(message: Message) -> None:
            if message["type"] == "http.response.start":
                trace.append(f"{self.name} response")
            await send(message)

        await self.app(scope, receive, send_wrapper)


async def session() -> AsyncIterator[str]:
    trace.append("dependency setup")
    yield "db"
    trace.append("dependency exit")


def after(name: str) -> None:
    trace.append(f"background task {name}")


app = FastAPI()
app.add_middleware(Tag, name="A")  # added first  -> innermost
app.add_middleware(Tag, name="B")  # added second -> outermost


@app.get("/books")
async def list_books(db: Annotated[str, Depends(session)], tasks: BackgroundTasks):
    trace.append("path operation")
    tasks.add_task(after, "x")
    return []


@app.get("/boom")
async def boom():
    raise HTTPException(status_code=404, detail="no such book")


client = TestClient(app)
client.get("/books")
print(trace)
trace.clear()
print(client.get("/boom").status_code, trace)
print([m.cls.__name__ + ":" + m.kwargs["name"] for m in app.user_middleware])
['B request', 'A request', 'dependency setup', 'path operation', 'A response', 'B response', 'background task x', 'dependency exit']
404 ['B request', 'A request', 'A response', 'B response']
['Tag:B', 'Tag:A']

Reading the trace:

  • The request goes B then A then the dependency and the path operation; the response comes back A then B. That matches the official statement: with add_middleware(MiddlewareA) followed by add_middleware(MiddlewareB) the request path is B then A then the route.

  • The response headers were sent before the background task ran. The official documentation says background tasks run after all middleware, and that exit code of a dependency with yield runs after the middleware as well. In this run the exit code came last, after the background task. The details of that ordering changed in 0.106.0 and again in 0.118.0; see Dependencies with yield for the rules.

  • The HTTPException produced a 404 that still passed through both layers as an ordinary response, because ExceptionMiddleware sits inside them.

The consequence for design is practical: put the middleware that must see (or reject) everything first, that is, add it last. A typical production order, from outermost to innermost, is TrustedHostMiddleware (reject bad Host headers before doing any work), HTTPSRedirectMiddleware, CORSMiddleware (so that rejections and redirects from inner layers still carry CORS headers where that matters), your own middleware, then GZipMiddleware innermost so that only the final body is compressed. The full stack is assembled and executed in A complete stack.

Function-Style Middleware and Its Limits

@app.middleware("http") is built on Starlette’s BaseHTTPMiddleware. That class is convenient, but Starlette’s documentation lists a known limitation: changes to a contextvars.ContextVar made further down the stack (in a path operation or dependency) do not propagate back up to the middleware, and a BaseHTTPMiddleware placed earlier in the stack also disrupts ContextVar propagation for pure ASGI middleware outside of it. The experiment below sets a context variable in a path operation and reads it from a middleware of each kind.

from contextvars import ContextVar

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send

current_user: ContextVar[str] = ContextVar("current_user", default="anonymous")


async def me() -> dict[str, str]:
    current_user.set("alice")  # set inside the path operation
    return {"user": current_user.get()}


# 1. Function-style middleware (runs on Starlette's BaseHTTPMiddleware)
http_app = FastAPI()
http_app.get("/me")(me)


@http_app.middleware("http")
async def http_style(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Seen-By-Middleware"] = current_user.get()
    return response


# 2. Pure ASGI middleware
class PureStyle:
    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        async def wrapper(message: Message) -> None:
            if message["type"] == "http.response.start":
                message.setdefault("headers", []).append(
                    (b"x-seen-by-middleware", current_user.get().encode()))
            await send(message)

        await self.app(scope, receive, wrapper)


pure_app = FastAPI()
pure_app.get("/me")(me)
pure_app.add_middleware(PureStyle)

for name, a in (("function-style", http_app), ("pure ASGI", pure_app)):
    r = TestClient(a).get("/me")
    print(name, r.json(), r.headers["x-seen-by-middleware"])
function-style {'user': 'alice'} anonymous
pure ASGI {'user': 'alice'} alice

This is the practical reason request-context features (logging correlation IDs, tracing, per-request caches stored in a ContextVar) are better written as pure ASGI middleware, which the next section shows. Two further rules from the Starlette documentation apply to class-based BaseHTTPMiddleware subclasses: the constructor must take app as its first argument and set it, and the instance must not be mutated per request (keep per-request state local to dispatch).

Pure ASGI Middleware

An ASGI middleware is a class whose constructor takes the next application and whose async def call(self, scope, receive, send) calls it. This is how Starlette’s own middleware is implemented, it works for HTTP and WebSocket scopes, it does not suffer from the BaseHTTPMiddleware limitations, and any ASGI middleware written for another framework can be used with app.add_middleware(…​). The three arguments are:

Table 1. The ASGI callable
Argument Meaning

scope

A dict describing the connection. scope["type"] is "http", "websocket" or "lifespan"; it also holds the path, headers (a list of byte pairs), query_string, root_path, client, scheme and a state mapping.

receive

A coroutine that returns the next event from the client (for HTTP, http.request messages with body and more_body).

send

A coroutine that sends an event to the client (for HTTP, http.response.start with status and headers, then http.response.body messages).

The example below is a production-shaped request ID middleware. It reads or creates the ID, stores it in the scope’s state (so request.state.request_id works in path operations and websocket.state.request_id in WebSockets) and in a ContextVar (for logging), echoes it on the response by wrapping send, and always resets the variable. A second class shows an eager response: answering without calling the application at all.

import uuid
from contextvars import ContextVar

from fastapi import FastAPI, Request, WebSocket
from fastapi.testclient import TestClient
from starlette.datastructures import Headers, MutableHeaders
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send

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


class RequestIDMiddleware:
    """Pure ASGI: reads or creates X-Request-ID, stores it, echoes it on the response."""

    def __init__(self, app: ASGIApp, header_name: str = "X-Request-ID") -> None:
        self.app = app
        self.header_name = header_name

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] not in ("http", "websocket"):
            await self.app(scope, receive, send)
            return

        request_id = Headers(scope=scope).get(self.header_name) or uuid.uuid4().hex
        scope.setdefault("state", {})["request_id"] = request_id  # request.state.request_id
        token = request_id_var.set(request_id)

        async def send_with_header(message: Message) -> None:
            if message["type"] == "http.response.start":
                MutableHeaders(scope=message)[self.header_name] = request_id
            await send(message)

        try:
            await self.app(scope, receive, send_with_header)
        finally:
            request_id_var.reset(token)


class MaintenanceMiddleware:
    """Pure ASGI: answers without calling the app (an 'eager response')."""

    def __init__(self, app: ASGIApp, enabled: bool = False) -> None:
        self.app = app
        self.enabled = enabled

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] == "http" and self.enabled:
            response = JSONResponse({"detail": "Down for maintenance"}, status_code=503,
                                    headers={"Retry-After": "120"})
            await response(scope, receive, send)
            return
        await self.app(scope, receive, send)


app = FastAPI()
app.add_middleware(RequestIDMiddleware)


@app.get("/ping")
async def ping(request: Request) -> dict[str, str]:
    return {"state": request.state.request_id, "contextvar": request_id_var.get()}


@app.websocket("/ws")
async def ws(websocket: WebSocket):
    await websocket.accept()
    await websocket.send_json({"request_id": websocket.state.request_id})
    await websocket.close()


client = TestClient(app)
r = client.get("/ping", headers={"X-Request-ID": "req-42"})
print(r.json(), r.headers["x-request-id"])
r = client.get("/ping")
print(r.json()["state"] == r.headers["x-request-id"], len(r.headers["x-request-id"]))
with client.websocket_connect("/ws", headers={"X-Request-ID": "ws-1"}) as s:
    print(s.receive_json())

down = FastAPI()
down.add_middleware(MaintenanceMiddleware, enabled=True)


@down.get("/ping")
async def p():
    return "pong"


r = TestClient(down).get("/ping")
print(r.status_code, r.headers["retry-after"], r.json())
{'state': 'req-42', 'contextvar': 'req-42'} req-42
True 32
{'request_id': 'ws-1'}
503 120 {'detail': 'Down for maintenance'}

The last line is the eager response: the status, the Retry-After header and the body, produced without the application being called.

The rules for writing ASGI middleware, taken from the Starlette documentation and confirmed by the examples:

  • Guard on scope["type"]. Pass through (await self.app(scope, receive, send)) anything you do not handle, in particular "lifespan" scopes, which carry no HTTP data.

  • Wrap send to touch the response. React to http.response.start (status, headers) and http.response.body (body chunks). If you change a body, update the Content-Length header to match.

  • Wrap receive to touch the request body. Messages of type http.request carry body and more_body.

  • Use Starlette helpers inside. Headers(scope=scope), MutableHeaders(scope=message), Request(scope), URL(scope=scope) and any Response (which is itself an ASGI application) are all usable from a middleware.

  • Stay stateless. One middleware instance serves all concurrent requests, so per-request state belongs in local variables of call, in scope["state"] or in a ContextVar, never in self.

  • Namespace what you put in the scope. Sharing data through the scope is a convention, not part of the ASGI specification, so choose keys unlikely to clash.

  • Type with starlette.types. ASGIApp, Scope, Receive, Send and Message keep the signatures readable.

Starlette 1.7 also ships ready-made middleware for common jobs, all added with add_middleware: SessionMiddleware (signed-cookie sessions), RequestBodyLimitMiddleware and, experimentally, an OpenTelemetryMiddleware. RequestBodyLimitMiddleware is the quickest way to protect an endpoint from huge uploads in the application itself (a proxy limit is still the first line of defence, see Forms & File Uploads):

from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.body_limit import RequestBodyLimitMiddleware

app = FastAPI()
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=1024)


@app.post("/reviews")
async def create_review(review: dict) -> dict:
    return review


c = TestClient(app)
print(c.post("/reviews", json={"text": "ok"}).status_code)
r = c.post("/reviews", json={"text": "x" * 5000})
print(r.status_code, r.text)
200
413 Content Too Large

CORS

The problem CORS solves

A browser enforces the same-origin policy: JavaScript loaded from one origin may not read responses from another origin. An origin is the combination of scheme, host and port, so http://localhost:5173, https://localhost:5173 and http://localhost:8000 are three different origins. The Bookshelf front end served from https://bookshelf.example calling an API at https://api.bookshelf.example is therefore cross-origin even though both share a domain. Cross-Origin Resource Sharing is the protocol by which the server tells the browser which other origins it is willing to share responses with. The concepts, the header semantics and the common mistakes are explained in the site’s What is CORS? page; this section is about configuring it in FastAPI.

Three facts shape everything below:

  • CORS is enforced by browsers only. curl, mobile apps and other servers ignore it, so it is not an access control mechanism. The server still executes a cross-origin request that the browser later refuses to let the page read; only a "non-simple" request is blocked from reaching your code by a failed preflight.

  • A request is simple (sent directly, with an Origin header) when it uses GET, HEAD or POST with only safelisted headers and content types. Anything else, notably PUT, PATCH, DELETE, a JSON Content-Type or an Authorization header, makes the browser first send a preflight: an OPTIONS request that asks permission.

  • Bearer-token APIs are called with the Authorization header, so in practice every call from a browser SPA to a token-protected FastAPI API is preflighted.

sequenceDiagram autonumber participant JS as SPA at https://bookshelf.example participant B as Browser participant API as FastAPI + CORSMiddleware JS->>B: fetch(POST /books, Authorization + JSON) Note over B: not a simple request: preflight first B->>API: OPTIONS /books
Origin: https://bookshelf.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type Note over API: CORSMiddleware answers itself, the route is never called API-->>B: 200 OK
Access-Control-Allow-Origin: https://bookshelf.example
Access-Control-Allow-Methods / -Headers / -Max-Age alt origin, method and headers allowed B->>API: POST /books
Origin + Authorization API-->>B: 201 Created
Access-Control-Allow-Origin + Access-Control-Expose-Headers B-->>JS: response readable by the page else not allowed API-->>B: 400 Disallowed CORS origin / method / headers B-->>JS: network error, the real request is never sent end

Adding CORSMiddleware

CORSMiddleware (fastapi.middleware.cors, re-exported from starlette.middleware.cors) is added with add_middleware. Its defaults are restrictive: with no arguments no origin, no method except GET and no custom header is allowed. The configuration below is the one the Bookshelf API uses: a local development origin, the production origin, and per-pull-request preview deployments matched with a regular expression.

from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient

app = FastAPI(title="Bookshelf API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "https://bookshelf.example.com"],
    allow_origin_regex=r"https://[a-z0-9-]+\.preview\.bookshelf\.example\.com",
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
    allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
    expose_headers=["X-Total-Count", "X-Request-ID"],
    max_age=600,
)


@app.get("/books")
async def list_books(response: Response) -> list[dict[str, str]]:
    response.headers["X-Total-Count"] = "1"
    return [{"title": "Dune"}]


@app.post("/books", status_code=201)
async def create_book() -> dict[str, str]:
    return {"title": "new"}


@app.get("/missing")
async def missing():
    raise HTTPException(status_code=404, detail="no such book")


@app.get("/crash")
async def crash():
    raise RuntimeError("bug")


client = TestClient(app, raise_server_exceptions=False)


def show(label, r):
    keep = {k: v for k, v in r.headers.items() if k.startswith("access-control") or k == "vary"}
    print(f"{label}: {r.status_code} {keep} {r.text[:40]!r}")


show("simple allowed", client.get("/books", headers={"Origin": "http://localhost:5173"}))
show("simple other origin", client.get("/books", headers={"Origin": "https://evil.example"}))
show("no Origin", client.get("/books"))
show("regex origin", client.get("/books", headers={"Origin": "https://pr-12.preview.bookshelf.example.com"}))
pre = {"Origin": "http://localhost:5173", "Access-Control-Request-Method": "POST",
       "Access-Control-Request-Headers": "authorization, content-type"}
show("preflight ok", client.options("/books", headers=pre))
show("preflight bad method", client.options("/books", headers={**pre, "Access-Control-Request-Method": "TRACE"}))
show("preflight bad header", client.options("/books", headers={**pre, "Access-Control-Request-Headers": "x-evil"}))
show("preflight bad origin", client.options("/books", headers={**pre, "Origin": "https://evil.example"}))
show("404 error", client.get("/missing", headers={"Origin": "http://localhost:5173"}))
show("500 error", client.get("/crash", headers={"Origin": "http://localhost:5173"}))
show("OPTIONS no preflight headers", client.options("/books", headers={"Origin": "http://localhost:5173"}))

The output shows the three kinds of request the middleware distinguishes (shortened here to the interesting headers):

simple allowed: 200 {'access-control-allow-credentials': 'true', 'access-control-expose-headers': 'X-Total-Count, X-Request-ID', 'access-control-allow-origin': 'http://localhost:5173', 'vary': 'Origin'} '[{"title":"Dune"}]'
simple other origin: 200 {'access-control-allow-credentials': 'true', 'access-control-expose-headers': 'X-Total-Count, X-Request-ID', 'vary': 'Origin'} '[{"title":"Dune"}]'
no Origin: 200 {'vary': 'Origin'} '[{"title":"Dune"}]'
regex origin: 200 {... 'access-control-allow-origin': 'https://pr-12.preview.bookshelf.example.com', 'vary': 'Origin'} ...
preflight ok: 200 {'vary': 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network', 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE', 'access-control-max-age': '600', 'access-control-allow-headers': 'Accept, Accept-Language, Authorization, Content-Language, Content-Type, X-Request-ID', 'access-control-allow-credentials': 'true', 'access-control-allow-origin': 'http://localhost:5173'} 'OK'
preflight bad method: 400 {... 'access-control-allow-origin': 'http://localhost:5173'} 'Disallowed CORS method'
preflight bad header: 400 {... 'access-control-allow-origin': 'http://localhost:5173'} 'Disallowed CORS headers'
preflight bad origin: 400 {... no access-control-allow-origin ...} 'Disallowed CORS origin'
404 error: 404 {... 'access-control-allow-origin': 'http://localhost:5173', 'vary': 'Origin'} '{"detail":"no such book"}'
500 error: 500 {} 'Internal Server Error'
OPTIONS no preflight headers: 405 {... 'access-control-allow-origin': 'http://localhost:5173', 'vary': 'Origin'} '{"detail":"Method Not Allowed"}'

The same preflight against a real server, with curl (the response of fastapi run cors_app.py):

curl -i -X OPTIONS http://127.0.0.1:8000/books \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization, content-type'
HTTP/1.1 200 OK
vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type, X-Request-ID
access-control-allow-credentials: true
access-control-allow-origin: http://localhost:5173
content-length: 2
content-type: text/plain; charset=utf-8

OK

What the experiment demonstrates:

  • A preflight is answered by the middleware. A request with method OPTIONS carrying both Origin and Access-Control-Request-Method never reaches the router, so it does not appear in your logs as a path operation and you must not declare OPTIONS routes for CORS. The response is 200 with the Access-Control-Allow-* headers or 400 with the plain-text reason (Disallowed CORS origin, method or headers).

  • A disallowed origin on a simple request is not blocked by the server. The route ran and returned 200; the response simply lacks Access-Control-Allow-Origin, so the browser refuses to expose it to the page. Never rely on CORS to keep a caller out.

  • Vary: Origin is added automatically so shared caches do not serve a response meant for one origin to another.

  • An OPTIONS request without the preflight headers is an ordinary request and is handled (here a 405) like any other, with CORS response headers added.

Every CORSMiddleware option

Table 2. CORSMiddleware arguments (Starlette 1.7)
Argument Default Meaning

allow_origins

() (none)

Exact origins (scheme, host and port, no path and no trailing slash) permitted to make cross-origin requests. ["*"] allows any origin.

allow_origin_regex

None

A regex matched against the whole origin string, evaluated in addition to allow_origins. Starlette’s documentation warns against .* and .` because they also match URL special characters (`/`, `@`, `#`, `?`) and so make the pattern over-permissive; use explicit character classes such as `[a-z0-9-] and escape the dots.

allow_methods

["GET"]

Methods allowed in cross-origin requests. [""] allows all standard methods (the run above showed DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, QUERY with [""] on Starlette 1.7).

allow_headers

[]

Request headers a cross-origin request may carry. ["*"] allows all. Accept, Accept-Language, Content-Language and Content-Type are always allowed (and are echoed in Access-Control-Allow-Headers, as the output shows).

allow_credentials

False

Whether the browser may send cookies, Authorization headers and TLS client certificates and expose the response. Adds Access-Control-Allow-Credentials: true. Needs an explicit origin, see below.

expose_headers

[]

Response headers beyond the CORS-safelisted ones that browser JavaScript may read (Access-Control-Expose-Headers). Without it, X-Total-Count or a custom X-Request-ID is invisible to the page.

max_age

600

Seconds a browser may cache a preflight result (Access-Control-Max-Age). Browsers cap it at their own maximum, so a large value is not honoured fully.

allow_private_network

False

Answers the Private Network Access preflight header Access-Control-Request-Private-Network with Access-Control-Allow-Private-Network: true. When False, such a preflight is rejected with 400.

The FastAPI documentation names the headers the browser must see in expose_headers; for the Bookshelf API that is X-Total-Count (pagination) and X-Request-ID (support requests).

Wildcards and credentials

The official documentation states that allow_origins, allow_methods and allow_headers cannot be [""] when allow_credentials=True; they must be listed explicitly. That is the rule the browser applies to a literal in the response header. The middleware itself behaves differently from what that sentence suggests, and the difference is a security trap. This run (Starlette 1.7) configures a wildcard together with credentials:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient


def build(**kw):
    app = FastAPI()
    app.add_middleware(CORSMiddleware, **kw)

    @app.get("/books")
    async def books():
        return []

    return app


def cors(r):
    return {k: v for k, v in r.headers.items() if k.startswith("access-control") or k == "vary"}


ORIGIN = {"Origin": "http://localhost:5173"}
pre = {**ORIGIN, "Access-Control-Request-Method": "DELETE", "Access-Control-Request-Headers": "authorization"}

print("defaults:", cors(TestClient(build()).get("/books", headers=ORIGIN)))
print("defaults preflight:", TestClient(build()).options("/books", headers=pre).status_code)
print("wildcard:", cors(TestClient(build(allow_origins=["*"])).get("/books", headers=ORIGIN)))
c = TestClient(build(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]))
print("wildcard+credentials:", cors(c.get("/books", headers=ORIGIN)))
r = c.options("/books", headers=pre)
print("wildcard+credentials preflight:", r.status_code, cors(r))
defaults: {'vary': 'Origin'}
defaults preflight: 400
wildcard: {'access-control-allow-origin': '*', 'vary': 'Origin'}
wildcard+credentials: {'access-control-allow-origin': 'http://localhost:5173', 'access-control-allow-credentials': 'true', 'vary': 'Origin'}
wildcard+credentials preflight: 200 {'vary': 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network', 'access-control-allow-methods': 'DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, QUERY', 'access-control-max-age': '600', 'access-control-allow-credentials': 'true', 'access-control-allow-origin': 'http://localhost:5173', 'access-control-allow-headers': 'authorization'}

With the wildcard and no credentials the response carries a literal . With the wildcard and credentials the middleware does not refuse to start: it reflects whatever Origin the browser sent into Access-Control-Allow-Origin (and reflects the requested headers), which makes the configuration equivalent to "every website may call this API with the visitor’s cookies". Treat allow_origins=[""] with allow_credentials=True as a vulnerability, not as a convenience, and list the origins explicitly (as the Bookshelf configuration does) whenever cookies or an Authorization header are involved. ["*"] with allow_credentials=False is acceptable for a genuinely public, unauthenticated, read-only API.

CORS and error responses

CORSMiddleware added with add_middleware is an inner layer: it sits inside ServerErrorMiddleware. An HTTPException (404 above) is converted to a response inside it, so it carries the CORS headers; an unhandled exception is converted to a 500 outside it, so that response has none (the 500 error: 500 {} line above). The browser then reports a confusing "CORS error" for what is really a server bug. Starlette’s documented remedy is to wrap the whole application in CORSMiddleware, so that even the last-resort 500 passes through it:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient

inner = FastAPI()


@inner.get("/crash")
async def crash():
    raise RuntimeError("bug")


ORIGIN = {"Origin": "http://localhost:5173"}
wrapped = CORSMiddleware(app=inner, allow_origins=["http://localhost:5173"])
r = TestClient(wrapped, raise_server_exceptions=False).get("/crash", headers=ORIGIN)
print("wrapped 500:", r.status_code, {k: v for k, v in r.headers.items() if k.startswith("access-control")})
wrapped 500: 500 {'access-control-allow-origin': 'http://localhost:5173'}

The server must be given the wrapped object (app = CORSMiddleware(app=inner, …​)) instead of inner, for example with uvicorn main:app; the wrapper is a plain ASGI application, not a FastAPI instance, so it has no app.add_middleware, no @app.get and no OpenAPI of its own (those stay on inner). A handler registered for Exception or 500 does not help here: FastAPI passes it to ServerErrorMiddleware, which is still outside your middleware, so its response has no CORS headers either (see Status Codes & Error Handling for exception handlers). Fixing the bug and logging the exception remains the real answer; the wrapper only makes the symptom readable in the browser console.

Practical CORS rules

  • Origins are exact. https://bookshelf.example and https://bookshelf.example/ are not the same string (the browser sends no trailing slash), http and https differ, and the port is part of the origin.

  • Credentials change the front end too. The browser only sends cookies or exposes the response to a call made with credentials: "include" (fetch) or withCredentials (XHR), and cookies must be SameSite=None; Secure for a true cross-site call; see Headers & Cookies.

  • Bearer tokens in the Authorization header need Authorization in allow_headers. They do not need allow_credentials=True; that flag is for cookies and for exposing responses to credentialed requests.

  • Cache of preflights. max_age avoids a preflight before every call; during development lower it so that configuration changes show up.

  • Do not stack CORS layers. If a gateway or proxy also adds Access-Control-Allow-Origin, browsers see duplicated or conflicting headers and reject the response. Configure CORS in exactly one place.

  • CORS does not apply to WebSockets. CORSMiddleware only handles HTTP scopes; the browser sends an Origin header on the WebSocket handshake but never blocks it, so check the origin yourself in the endpoint (WebSockets).

  • Same origin needs none of this. Serving the SPA and the API under one origin (the API under /api, as in Path prefixes and root_path) removes CORS entirely and is often the simplest production design.

GZipMiddleware, TrustedHostMiddleware and HTTPSRedirectMiddleware

FastAPI re-exports three more Starlette middleware in fastapi.middleware as a convenience (fastapi.middleware.gzip, .trustedhost and .httpsredirect); the Starlette import paths work identically.

from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient

# ---- GZip
gz = FastAPI()
gz.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)


@gz.get("/books")
async def books() -> list[dict[str, str]]:
    return [{"title": f"Book {i}", "author": "Frank Herbert"} for i in range(200)]


@gz.get("/small")
async def small() -> dict[str, str]:
    return {"ok": "yes"}


@gz.get("/feed")
async def feed():
    async def gen():
        for i in range(3):
            yield f"data: {i}\n\n"
    return StreamingResponse(gen(), media_type="text/event-stream")


c = TestClient(gz)
for path in ("/books", "/small", "/feed"):
    r = c.get(path, headers={"Accept-Encoding": "gzip"})
    print(path, r.status_code, r.headers.get("content-encoding"), r.headers.get("vary"), r.headers.get("content-length"))
raw = c.get("/books", headers={"Accept-Encoding": "identity"})
print("identity", raw.headers.get("content-encoding"), len(raw.content))

# ---- TrustedHost
th = FastAPI()
th.add_middleware(TrustedHostMiddleware, allowed_hosts=["api.bookshelf.example", "*.bookshelf.example"])


@th.get("/")
async def root():
    return {"ok": True}


t = TestClient(th, base_url="http://api.bookshelf.example")
print("trusted", t.get("/").status_code)
r = TestClient(th, base_url="http://evil.example").get("/")
print("untrusted", r.status_code, r.text)
print("sub", TestClient(th, base_url="http://www.bookshelf.example").get("/").status_code)
print("bare wildcard base", TestClient(th, base_url="http://bookshelf.example").get("/").status_code)
print("host header override", t.get("/", headers={"Host": "evil.example"}).status_code)

# ---- HTTPS redirect
hr = FastAPI()
hr.add_middleware(HTTPSRedirectMiddleware)


@hr.get("/books")
async def b():
    return []


r = TestClient(hr, base_url="http://api.bookshelf.example").get("/books?limit=5", follow_redirects=False)
print("http", r.status_code, r.headers["location"])
print("https", TestClient(hr, base_url="https://api.bookshelf.example").get("/books").status_code)
/books 200 gzip Accept-Encoding 557
/small 200 None None 12
/feed 200 None None None
identity None 9091
trusted 200
untrusted 400 Invalid host header
sub 200
bare wildcard base 400
host header override 400
http 307 https://api.bookshelf.example/books?limit=5
https 200
Table 3. The three built-in middleware
Middleware Behaviour and options

GZipMiddleware

Compresses responses when the request’s Accept-Encoding includes gzip, for ordinary and streaming responses, and adds Vary: Accept-Encoding. minimum_size (default 500 bytes) skips small bodies (/small above is untouched); compresslevel (1 to 9, default 9; lower is faster and larger). Starlette 1.7 adds thread_minimum_size (chunks of at least 128 KiB are compressed in a worker thread to keep the event loop responsive) and exclude_content_types (by default text/event-stream and already-compressed formats such as images, audio, video and archives, so the SSE /feed above was left alone). It never re-encodes a response that already has a Content-Encoding and skips 206 partial responses. The 200 books shrank from 9091 to 557 bytes on the wire.

TrustedHostMiddleware

Rejects requests whose Host header does not match allowed_hosts with 400 Invalid host header, which protects against HTTP Host header attacks (poisoned password-reset links built from the host, cache poisoning). Entries may be exact names or .example.com wildcards. A wildcard such as .bookshelf.example does not match the apex domain bookshelf.example (the bare wildcard base line returned 400), so list both. ["*"] allows everything, and IPv6 literals are written in brackets (["[::1]"]). www_redirect=True (the default) redirects a non-www host to its www counterpart when the www. form is allowed. It applies to WebSocket connections too.

HTTPSRedirectMiddleware

Redirects http and ws requests to https and wss with a 307, preserving path and query string. It has no options. It decides from the scheme the application sees, so behind a TLS-terminating proxy it needs the forwarded headers described below, or it redirects forever.

Because these middleware read scope["scheme"], scope["client"] and the Host header, their behaviour changes when you move the app behind a proxy: Running Behind a Proxy shows the same HTTPSRedirectMiddleware and TrustedHostMiddleware against a real server.

A complete stack

Putting the options together, the following app uses the production order recommended above. add_middleware is called innermost first, so app.user_middleware prints the layers outermost first.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.testclient import TestClient

app = FastAPI(title="Bookshelf API")

# add_middleware() wraps the app: the LAST call becomes the OUTERMOST layer.
app.add_middleware(GZipMiddleware, minimum_size=500)                 # innermost
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://bookshelf.example"],
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
    expose_headers=["X-Total-Count"],
)
app.add_middleware(HTTPSRedirectMiddleware)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["api.bookshelf.example"])  # outermost


@app.get("/books")
async def list_books() -> list[dict[str, str]]:
    return [{"title": f"Book {i}"} for i in range(100)]


print([m.cls.__name__ for m in app.user_middleware])  # outermost first
c = TestClient(app, base_url="https://api.bookshelf.example")
r = c.get("/books", headers={"Origin": "https://bookshelf.example", "Accept-Encoding": "gzip"})
print(r.status_code, r.headers["access-control-allow-origin"], r.headers["content-encoding"])
r = TestClient(app, base_url="http://api.bookshelf.example").get("/books", follow_redirects=False)
print(r.status_code, r.headers["location"])
r = TestClient(app, base_url="https://other.example").get("/books")
print(r.status_code, r.text)
['TrustedHostMiddleware', 'HTTPSRedirectMiddleware', 'CORSMiddleware', 'GZipMiddleware']
200 https://bookshelf.example gzip
307 https://api.bookshelf.example/books?limit=5
400 Invalid host header

None of these middleware changes the OpenAPI document: the /docs UI, openapi.json and the generated clients do not know about CORS, compression or host validation. The one place the deployment topology does reach OpenAPI is the servers field described in Path prefixes and root_path.

Running Behind a Proxy

In production an application server such as Uvicorn rarely faces the internet. A reverse proxy (Traefik, Nginx, Caddy, HAProxy, a cloud load balancer, a Kubernetes ingress) terminates TLS, routes by host or path, balances across instances and absorbs slow clients. That has two effects on the app, which are the subject of the rest of the page:

  1. The connection the app sees comes from the proxy, over plain HTTP, so the client’s real IP, the original scheme (https) and the original host are lost unless the proxy forwards them and the server is willing to believe it.

  2. The proxy may serve the app under a path prefix (/api/v1) that it removes before forwarding, so the app does not know its own public URL.

The figure at What Middleware Is showed middleware inside the app; the proxy is one more layer outside it, in the same onion spirit: client, proxy, ASGI server, middleware, your code.

Forwarded headers and --forwarded-allow-ips

A proxy tells the app what it hid through X-Forwarded- headers (the standardised form is the Forwarded header of RFC 7239; practically every proxy still sends the X-Forwarded- ones):

Header Original information

X-Forwarded-For

The client’s IP address (a list when several proxies are chained).

X-Forwarded-Proto

The scheme the client used, https or http.

X-Forwarded-Host

The Host the client asked for.

The ASGI server is the component that interprets them. Uvicorn (which fastapi run and fastapi dev use) applies its ProxyHeadersMiddleware to populate scope["client"] and scope["scheme"] from X-Forwarded-For and X-Forwarded-Proto, but, because any client can forge a header, only for connections that come from a trusted IP.

Table 4. Proxy-related flags (fastapi run, fastapi dev and uvicorn)
Flag Meaning

--proxy-headers / --no-proxy-headers

Enable or disable interpreting forwarded headers. Enabled by default, but only for trusted connecting IPs.

--forwarded-allow-ips <list>

Comma-separated IP addresses, networks or literals (a UNIX socket path) allowed to set proxy headers; * trusts every source. Defaults to the FORWARDED_ALLOW_IPS environment variable, otherwise to 127.0.0.1 and ::1.

--root-path <str>

Sets the ASGI root_path, for apps served below a path prefix (see below).

The official guidance is to pass --forwarded-allow-ips with the proxy’s address, or "" when only the proxy can reach the server (a private container network). Never use "" when the app port is reachable from outside the proxy: anyone could then claim any client IP and scheme.

The experiment below runs a real fastapi run server for each setting and sends the headers a proxy would add with curl. The path operation returns what the app believes about the request. The route is declared with a trailing slash and the request omits it, so the framework’s slash redirect also shows which scheme it builds Location URLs with.

from fastapi import FastAPI, Request

app = FastAPI(title="Bookshelf API")


@app.get("/items/")
def read_items(request: Request) -> dict[str, str | None]:
    return {
        "scheme": request.url.scheme,
        "client": request.client.host if request.client else None,
        "base_url": str(request.base_url),
        "root_path": request.scope.get("root_path"),
    }
fastapi run main_proxy.py --port 8101                                   # defaults
fastapi run main_proxy.py --port 8102 --no-proxy-headers
fastapi run main_proxy.py --port 8103 --forwarded-allow-ips 192.0.2.1   # trusts another IP only
fastapi run main_proxy.py --port 8104 --forwarded-allow-ips "*" --root-path /api/v1

curl -s -i -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 203.0.113.9' \
  -H 'X-Forwarded-Host: bookshelf.example' http://127.0.0.1:8101/items
curl -s -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 203.0.113.9' \
  -H 'X-Forwarded-Host: bookshelf.example' http://127.0.0.1:8101/items/
Table 5. Observed results (curl came from 127.0.0.1, which Uvicorn trusts by default)
Server flags Location of the 307 slash redirect Body of /items/

defaults

https://127.0.0.1:8101/items/

{"scheme":"https","client":"203.0.113.9","base_url":"https://127.0.0.1:8101/","root_path":""}

--no-proxy-headers

http://127.0.0.1:8102/items/

{"scheme":"http","client":"127.0.0.1","base_url":"http://127.0.0.1:8102/","root_path":""}

--forwarded-allow-ips 192.0.2.1

http://127.0.0.1:8103/items/

{"scheme":"http","client":"127.0.0.1","base_url":"http://127.0.0.1:8103/","root_path":""}

--forwarded-allow-ips "*" --root-path /api/v1

https://127.0.0.1:8104/api/v1/items/

{"scheme":"https","client":"203.0.113.9","base_url":"https://127.0.0.1:8104/api/v1/","root_path":"/api/v1"}

What the table shows:

  • With a trusted caller the app sees https and the real client address, so request.url, request.base_url, request.url_for(…​) and redirects are correct. Without trust (flag --no-proxy-headers, or a caller whose IP is not in --forwarded-allow-ips) the forged headers are ignored and the app believes it is on plain HTTP. This is the failure that makes generated links, redirects and OAuth redirect URIs point at http:// addresses.

  • The redirect from /items to /items/ is the official example for this feature: without forwarded headers the redirect leaves the HTTPS site for http://.

  • In this run, Uvicorn used the scheme and client address from the headers but kept the Host it was actually called with (127.0.0.1:810x), despite the X-Forwarded-Host header: Uvicorn’s ProxyHeadersMiddleware documents only X-Forwarded-For and X-Forwarded-Proto. The proxy therefore has to forward the original Host header itself (Traefik does by default; Nginx needs proxy_set_header Host $host;), which is also what makes TrustedHostMiddleware work behind it. The FastAPI page lists X-Forwarded-Host among the proxy headers; do not rely on it unless you have checked your Uvicorn version.

  • fastapi run and uvicorn accept the same three flags; in Docker, set the environment variable FORWARDED_ALLOW_IPS instead of editing the command, for example FORWARDED_ALLOW_IPS="*" on a private network. The official Dockerfile page suggests adding --proxy-headers to the fastapi run command when running behind a proxy such as Nginx or Traefik.

Proxy headers, HTTPSRedirectMiddleware and TrustedHostMiddleware together

The following app combines the two scheme- and host-dependent middleware with trusted-proxy headers. It was run four ways with curl:

from fastapi import FastAPI
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware

app = FastAPI(title="Bookshelf API")
app.add_middleware(HTTPSRedirectMiddleware)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["bookshelf.example", "127.0.0.1"])


@app.get("/books")
async def list_books() -> list[str]:
    return ["Dune"]
Table 6. Observed results
Server flags and request Status Why

--no-proxy-headers, plain HTTP, X-Forwarded-Proto: https

307 to https://127.0.0.1:8110/books

The forwarded header is ignored, the app sees http and redirects.

--forwarded-allow-ips 127.0.0.1, X-Forwarded-Proto: https

200

The trusted proxy says the client used HTTPS, so no redirect.

same, X-Forwarded-Proto: http

307 to https://127.0.0.1:8111/books

A genuinely plain-HTTP client is redirected.

same, X-Forwarded-Proto: https, Host: evil.example

400 Invalid host header

TrustedHostMiddleware checks the real Host.

The classic failure is an infinite redirect loop: the proxy terminates TLS and talks plain HTTP to the app; if the server does not trust the forwarded scheme, HTTPSRedirectMiddleware answers every request with a redirect to https, the proxy forwards the next request over HTTP again, and the browser gives up. Either configure --forwarded-allow-ips correctly, or leave HTTPSRedirectMiddleware out and do the HTTP-to-HTTPS redirect at the proxy (the better design, see HTTPS and TLS Termination).

Path prefixes and root_path

Often one proxy serves several applications on one domain, each under its own prefix: the Bookshelf API at https://example.com/api/v1/…​;, the front end at /. The proxy can either forward the full path unchanged or strip the prefix and forward /books to an app that only knows /books. Stripping is convenient (the app’s code does not repeat /api/v1), but then the app no longer knows where it is publicly mounted, and that breaks three things:

  • the Swagger UI at /api/v1/docs would fetch /openapi.json instead of /api/v1/openapi.json;

  • request.url_for(…​), redirects and Location headers would omit the prefix;

  • the OpenAPI servers list would not tell client generators and the "Try it out" button where the API lives.

The ASGI specification has a field for exactly this: scope["root_path"], the path prefix under which the app is mounted. It is also what Starlette uses internally when you mount sub-applications. You can provide it in two equivalent ways:

  • From the server: fastapi run main.py --root-path /api/v1 (Uvicorn and Hypercorn have the same --root-path option).

  • From the application: FastAPI(root_path="/api/v1"), for when you cannot change the command line.

import json

from fastapi import FastAPI, Request
from fastapi.testclient import TestClient

app = FastAPI(title="Bookshelf API", root_path="/api/v1")


@app.get("/app")
def read_main(request: Request) -> dict[str, str | None]:
    return {
        "message": "Hello World",
        "root_path": request.scope.get("root_path"),
        "url_for": str(request.url_for("read_main")),
    }


client = TestClient(app)
print("stripped prefix  /app        ->", client.get("/app").json())
r = client.get("/api/v1/app")
print("unstripped       /api/v1/app ->", r.status_code, r.json())
spec = client.get("/openapi.json").json()
print("servers:", spec.get("servers"))
html = client.get("/docs").text
i = html.find("url:")
print("docs html:", html[i:i + 40])

# no root_path: no servers key
plain = TestClient(FastAPI())
print("plain servers:", plain.get("/openapi.json").json().get("servers", "<omitted>"))

# root_path supplied by the ASGI server (what `--root-path` does) instead of the constructor
no_arg = FastAPI()


@no_arg.get("/app")
def m(request: Request):
    return {"root_path": request.scope["root_path"]}


c2 = TestClient(no_arg, root_path="/api/v1")
print("server root_path:", c2.get("/app").json(), c2.get("/openapi.json").json().get("servers"))

# extra servers
multi = FastAPI(
    servers=[
        {"url": "https://stag.example.com", "description": "Staging environment"},
        {"url": "https://prod.example.com", "description": "Production environment"},
    ],
    root_path="/api/v1",
)
print("servers (with root_path):", [s["url"] for s in TestClient(multi).get("/openapi.json").json()["servers"]])
multi2 = FastAPI(servers=multi.servers, root_path="/api/v1", root_path_in_servers=False)
print("servers (root_path_in_servers=False):", [s["url"] for s in TestClient(multi2).get("/openapi.json").json()["servers"]])
stripped prefix  /app        -> {'message': 'Hello World', 'root_path': '/api/v1', 'url_for': 'http://testserver/api/v1/app'}
unstripped       /api/v1/app -> 200 {'message': 'Hello World', 'root_path': '/api/v1', 'url_for': 'http://testserver/api/v1/app'}
servers: [{'url': '/api/v1'}]
docs html: url: '/api/v1/openapi.json',
    "dom_id
plain servers: <omitted>
server root_path: {'root_path': '/api/v1'} [{'url': '/api/v1'}]
servers (with root_path): ['/api/v1', 'https://stag.example.com', 'https://prod.example.com']
servers (root_path_in_servers=False): ['https://stag.example.com', 'https://prod.example.com']

What this establishes, all from the run above:

  • Routing is unaffected by root_path for a stripping proxy. The route stays /app; the prefix is only metadata used to build URLs. Uvicorn does not use it to route and never expects to be called with the prefix.

  • A non-stripping proxy also works. A request for /api/v1/app matched the same route, because the router removes a leading root_path from the path before matching. A proxy that forwards the full path therefore needs the same root_path setting and no application code changes. If it forwards the full path and you set no root_path, the routes would have to be declared with the prefix (an APIRouter(prefix="/api/v1")).

  • url_for, the docs and OpenAPI follow root_path. The Swagger UI now asks for /api/v1/openapi.json; the same root_path is prepended to oauth2-redirect and the ReDoc URL.

  • servers. With a root_path, FastAPI adds {"url": "/api/v1"} as the first server. If you supply your own servers list (for example staging and production, so Swagger UI can target either), the root_path entry is inserted at the beginning unless you pass root_path_in_servers=False. With no root_path the servers key is omitted entirely, which means a single server at /.

Stripped prefix, end to end

The official page demonstrates a stripped prefix with Traefik. To see the same mechanics without installing a proxy, the experiment below puts a 30-line Python stand-in for Traefik’s stripPrefix in front of the app (it removes /api/v1, forwards Host and adds X-Forwarded-Proto: https; it is a lab tool, never a production proxy). The application is the one from the official page:

from fastapi import FastAPI, Request

app = FastAPI(title="Bookshelf API")


@app.get("/app")
def read_main(request: Request) -> dict[str, str | None]:
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}
# the app, told where it is mounted publicly
fastapi run main_prefix.py --port 8000 --forwarded-allow-ips "*" --root-path /api/v1

curl -s localhost:8000/app                  # direct to the app, as only the proxy should
curl -s localhost:9999/api/v1/app           # through the stripping proxy
curl -s localhost:9999/api/v1/docs | grep -o "url: '[^']*'"
{"message":"Hello World","root_path":"/api/v1"}      (direct: the app answers on /app, root_path is metadata)
{"message":"Hello World","root_path":"/api/v1"}      (through the proxy at /api/v1/app)
url: '/api/v1/openapi.json'                          (the docs UI fetches the schema under the prefix)

Restarting the same app without --root-path and calling it through the proxy gives the failure the setting exists to prevent: the route still works, but root_path is empty and /api/v1/docs asks for the schema at the wrong place:

{"message":"Hello World","root_path":""}
url: '/openapi.json'

The browser would request https://example.com/openapi.json, which the proxy routes somewhere else (or to a 404), and Swagger UI shows "Failed to load API definition".

Mind these points about the stripped-prefix setup:

  • The header Traefik adds is not used. Traefik’s stripPrefix middleware stores the removed prefix in an X-Forwarded-Prefix header, but neither Uvicorn nor FastAPI reads it (a search of FastAPI, Starlette and Uvicorn finds no use of it). Tell the app the prefix yourself with --root-path or FastAPI(root_path=…​).

  • Keep them in sync. The prefix configured in the proxy and the root_path in the app are two copies of one fact. Read it from settings (Settings, Lifespan & Background Tasks), not a literal, and do not set both --root-path and a FastAPI(root_path=…​) to different values.

  • Do not double the prefix. With a stripping proxy the routes and routers stay unprefixed. Adding /api/v1 to an APIRouter and stripping it at the proxy would require /api/v1/api/v1/books.

  • Mounted sub-applications work. When you mount a sub-application while using root_path, FastAPI combines the two correctly; the sub-application’s own docs and OpenAPI use their mount path (see Sub-Applications, WSGI & GraphQL).

  • Redirects and Location headers are built from root_path plus the scheme the app sees, which is why the forwarded headers and the prefix are usually configured together, as in the last row of the earlier table.

Traefik

Traefik is the proxy the official documentation uses for these examples, and a popular choice for Docker and Compose deployments because it discovers containers and obtains Let’s Encrypt certificates by itself. The official local experiment uses two files. traefik.toml declares an entry point on port 9999 (a non-privileged port, so no sudo) and a file provider:

[entryPoints]
  [entryPoints.http]
    address = ":9999"
[providers]
  [providers.file]
    filename = "routes.toml"

and routes.toml routes the prefix, removes it and forwards to Uvicorn:

[http]
  [http.middlewares]
    [http.middlewares.api-stripprefix.stripPrefix]
      prefixes = ["/api/v1"]
  [http.routers]
    [http.routers.app-http]
      entryPoints = ["http"]
      service = "app"
      rule = "PathPrefix(`/api/v1`)"
      middlewares = ["api-stripprefix"]
  [http.services]
    [http.services.app]
      [http.services.app.loadBalancer]
        [[http.services.app.loadBalancer.servers]]
          url = "http://127.0.0.1:8000"

Start Traefik with ./traefik --configFile=traefik.toml, then the app with fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1. http://127.0.0.1:8000/app (Uvicorn directly) and http://127.0.0.1:9999/api/v1/app (through Traefik) return the same JSON, and the docs UI works only at http://127.0.0.1:9999/api/v1/docs. The Traefik files above are reproduced from the official page; Traefik was not installed in the environment where this page was verified, and the stand-in proxy of the previous section exercised the same app-side behaviour.

In Docker, Traefik’s Docker provider reads labels from the container instead of a routes file. The StripPrefix middleware accepts its configuration as a label, traefik.http.middlewares.<name>.stripprefix.prefixes, so a Compose service for the Bookshelf API can look like this sketch (the router rule, entry point name and network are examples to adapt to your Traefik installation):

services:
  api:
    build: .
    command: ["fastapi", "run", "app/main.py", "--port", "8000", "--forwarded-allow-ips", "*", "--root-path", "/api/v1"]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.bookshelf-api.rule=Host(`bookshelf.example`) && PathPrefix(`/api/v1`)"
      - "traefik.http.routers.bookshelf-api.entrypoints=websecure"
      - "traefik.http.routers.bookshelf-api.tls.certresolver=letsencrypt"
      - "traefik.http.routers.bookshelf-api.middlewares=bookshelf-strip"
      - "traefik.http.middlewares.bookshelf-strip.stripprefix.prefixes=/api/v1"
      - "traefik.http.services.bookshelf-api.loadbalancer.server.port=8000"

This Compose fragment is a sketch assembled from Traefik’s documented label forms and was not run for this page; the websecure entry point and letsencrypt resolver must be defined in Traefik’s own static configuration. "*" is acceptable for --forwarded-allow-ips here only because the container is reachable solely from Traefik on the Compose network and publishes no port to the host. Compose itself, networks and health checks are covered in Compose in Practice; for Kubernetes the equivalent of the proxy is an ingress or Gateway API implementation (Ingress), and the role of an edge gateway in a larger system is discussed in API Gateway & BFF. Nginx, Caddy and cloud load balancers need the same three things: forward the original Host, send X-Forwarded-For and X-Forwarded-Proto, and (if they strip a prefix) tell the app with --root-path.

HTTPS and TLS Termination

FastAPI and Uvicorn can serve HTTPS themselves (Uvicorn has --ssl-keyfile and --ssl-certfile), but the official deployment guidance is to let a TLS termination proxy do it: it holds the certificates (and renews them), speaks HTTPS to the world and plain HTTP to the application over a private network. Traefik, Caddy, Nginx and HAProxy are the options the FastAPI documentation lists; Traefik and Caddy also renew Let’s Encrypt certificates on their own. The practical consequences for the application are the settings of this page:

Table 7. HTTPS checklist for the app behind a TLS-terminating proxy
Concern What to do

The app must know the request was HTTPS

Run with --forwarded-allow-ips set to the proxy (or FORWARDED_ALLOW_IPS) and make the proxy send X-Forwarded-Proto. Otherwise request.url, request.url_for, redirects, OAuth redirect URIs and Set-Cookie …​ Secure logic see http.

Redirecting http to https

Prefer the proxy (an entry-point or router redirect on port 80). HTTPSRedirectMiddleware in the app works only if the forwarded headers are trusted and otherwise loops forever, as shown earlier.

HSTS (Strict-Transport-Security)

FastAPI has no built-in middleware for it. Add it at the proxy, or with a small pure ASGI middleware that appends the header in http.response.start, only on responses that were served over HTTPS.

Host validation

Forward the original Host and add TrustedHostMiddleware with the public names. A wildcard does not match the apex domain.

Client IP

Use request.client.host (set from X-Forwarded-For for a trusted proxy), not the header itself, in rate limiting and audit logs. Taking a forwarded header from an untrusted caller lets anyone spoof an address.

Certificates and renewal

Handled by the proxy (Traefik, Caddy and others support Let’s Encrypt/ACME); the app never sees the key. The FastAPI "About HTTPS" page explains SNI, ports 80/443 and renewal.

WebSockets

A proxy must pass the upgrade; the scheme becomes wss through the same forwarded header, and HTTPSRedirectMiddleware treats ws like http.

The scaling topology that results (a TLS proxy, several container replicas each running one Uvicorn process, then the database) and the official Dockerfile with fastapi run are in Deployment; the site’s OAuth Security Best Practices covers why redirect URIs and cookies must be HTTPS only.

Where Older Guides Are Outdated, and What Changed Recently

  • Order is a stack. Material that lists middleware as a flat sequence leaves out that the last one added is the outermost (the FastAPI tutorial now states this rule explicitly), so "add CORSMiddleware first" is the wrong instinct whenever other middleware can reject or redirect a request.

  • CORS wildcard with credentials. Many tutorials set allow_origins=["*"] with allow_credentials=True side by side. The documentation forbids the combination, and the middleware reflects the caller’s origin instead of failing, which is a security hole (see CORS).

  • --proxy-headers. Early deployments passed --proxy-headers and trusted everything. Uvicorn’s default now trusts only 127.0.0.1 and ::1 and the proper switch is --forwarded-allow-ips; the FastAPI documentation still mentions --proxy-headers in the Docker page, which is the (default-on) enable flag, not the trust list.

  • root_path instead of openapi_prefix. The old openapi_prefix argument was deprecated in favour of root_path; servers is generated from it (and omitted when there is none). root_path_in_servers=False switches the automatic entry off.

  • Prefix handling in routing. Current Starlette removes a leading root_path from the path before routing, so a proxy that forwards the unstripped path also works with root_path set.

  • Dependency exit code and background tasks. Where a dependency with yield exits relative to the response changed in 0.106.0 and again in 0.118.0 (the exit code now runs after the response has been sent); middleware that relied on resources still being open during the response must be revisited. See Dependencies with yield.

  • Built-in middleware list. Starlette 1.x added RequestBodyLimitMiddleware, an experimental OpenTelemetry middleware, and options such as GZipMiddleware(thread_minimum_size=, exclude_content_types=) and CORSMiddleware(allow_private_network=). The "*" method wildcard now includes QUERY.

  • Native OpenTelemetry (0.142.0, 2026-09-29). FastAPI can emit traces itself, so a hand-written timing middleware is no longer the only way to measure request durations; see What’s Changed & Migration.

  • Do not hand-roll CORS. Writing Access-Control-* headers in a function middleware, as some tutorials do, forgets the preflight, Vary: Origin and the credentials rules. Use CORSMiddleware.

Pitfalls

  • Middleware that reads the body. Calling await request.body() in a function middleware worked in a run on this baseline (the endpoint still received the body), but it buffers the entire body in memory before the endpoint starts, which defeats streaming uploads. Limit sizes with RequestBodyLimitMiddleware or the proxy, or use a pure ASGI middleware that wraps receive and counts chunks.

  • Slow work in middleware. It runs for every request, including /docs, health checks and 404s; scope work with request.url.path checks or put it in a dependency.

  • Exceptions in call_next. In a run, an unhandled RuntimeError from a path operation was raised out of call_next inside the middleware (the 500 is produced later, by the outer ServerErrorMiddleware), so wrap the call in try/finally if you must clean up or record metrics.

  • State on the middleware instance. One instance serves all requests concurrently; mutating self per request is a race condition.

  • CORSMiddleware after authentication in a middleware. If an inner layer rejects the preflight with 401 before CORS sees it, the browser reports a CORS error. CORS must be outside (added after) anything that can reject OPTIONS.

  • Authorization and CORS. A CORS error in the console can be a 500, a 401 or a proxy error with no CORS headers; look at the network tab for the real status.

  • allow_origins=[""] with credentials, trailing slashes in origins, . in allow_origin_regex. All covered above.

  • Trusting X-Forwarded- from everyone.* --forwarded-allow-ips "*" on a port that is reachable without the proxy allows spoofing of client IP and scheme.

  • Two layers redirecting. A proxy that redirects to HTTPS and an app with HTTPSRedirectMiddleware can ping-pong when the scheme is not forwarded.

  • Docs working locally but not behind the proxy. Almost always a missing root_path; check the servers entry and the url: in the docs HTML.

  • Proxy request-size and timeout limits. The proxy’s own limits (body size, idle timeout) apply before the app’s RequestBodyLimitMiddleware; streaming and SSE need buffering disabled at the proxy (see Streaming, JSON Lines & SSE).

  • Compression of streams. GZipMiddleware compresses each chunk it receives; for SSE it is excluded by default, and a proxy that buffers responses defeats streaming regardless.