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.
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
ExceptionMiddlewareis inside your middleware, anHTTPExceptionraised by a path operation has already become a normal response by the time your middleware sees it. An exception that is not handled, a plainRuntimeError, travels outward through your middleware as an exception and is only converted to a500byServerErrorMiddleware, 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)
Source: FastAPI: Middleware
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 forapp.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 CORSexpose_headers(see CORS). -
call_nexthands 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
BthenAthen the dependency and the path operation; the response comes backAthenB. That matches the official statement: withadd_middleware(MiddlewareA)followed byadd_middleware(MiddlewareB)the request path isBthenAthen 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
yieldruns 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 withyieldfor the rules. -
The
HTTPExceptionproduced a404that still passed through both layers as an ordinary response, becauseExceptionMiddlewaresits 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:
| Argument | Meaning |
|---|---|
|
A dict describing the connection. |
|
A coroutine that returns the next event from the client (for HTTP, |
|
A coroutine that sends an event to the client (for HTTP, |
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
sendto touch the response. React tohttp.response.start(status, headers) andhttp.response.body(body chunks). If you change a body, update theContent-Lengthheader to match. -
Wrap
receiveto touch the request body. Messages of typehttp.requestcarrybodyandmore_body. -
Use Starlette helpers inside.
Headers(scope=scope),MutableHeaders(scope=message),Request(scope),URL(scope=scope)and anyResponse(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, inscope["state"]or in aContextVar, never inself. -
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,SendandMessagekeep 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
Originheader) when it usesGET,HEADorPOSTwith only safelisted headers and content types. Anything else, notablyPUT,PATCH,DELETE, a JSONContent-Typeor anAuthorizationheader, makes the browser first send a preflight: anOPTIONSrequest that asks permission. -
Bearer-token APIs are called with the
Authorizationheader, so in practice every call from a browser SPA to a token-protected FastAPI API is preflighted.
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
OPTIONScarrying bothOriginandAccess-Control-Request-Methodnever reaches the router, so it does not appear in your logs as a path operation and you must not declareOPTIONSroutes for CORS. The response is200with theAccess-Control-Allow-*headers or400with the plain-text reason (Disallowed CORS origin,methodorheaders). -
A disallowed origin on a simple request is not blocked by the server. The route ran and returned
200; the response simply lacksAccess-Control-Allow-Origin, so the browser refuses to expose it to the page. Never rely on CORS to keep a caller out. -
Vary: Originis added automatically so shared caches do not serve a response meant for one origin to another. -
An
OPTIONSrequest without the preflight headers is an ordinary request and is handled (here a405) like any other, with CORS response headers added.
Every CORSMiddleware option
| Argument | Default | Meaning |
|---|---|---|
|
|
Exact origins (scheme, host and port, no path and no trailing slash) permitted to make cross-origin requests.
|
|
|
A regex matched against the whole origin string, evaluated in addition to |
|
|
Methods allowed in cross-origin requests. |
|
|
Request headers a cross-origin request may carry. |
|
|
Whether the browser may send cookies, |
|
|
Response headers beyond the CORS-safelisted ones that browser JavaScript may read ( |
|
|
Seconds a browser may cache a preflight result ( |
|
|
Answers the Private Network Access preflight header |
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.exampleandhttps://bookshelf.example/are not the same string (the browser sends no trailing slash),httpandhttpsdiffer, 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) orwithCredentials(XHR), and cookies must beSameSite=None; Securefor a true cross-site call; see Headers & Cookies. -
Bearer tokens in the
Authorizationheader needAuthorizationinallow_headers. They do not needallow_credentials=True; that flag is for cookies and for exposing responses to credentialed requests. -
Cache of preflights.
max_ageavoids 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.
CORSMiddlewareonly handles HTTP scopes; the browser sends anOriginheader 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 androot_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)
Source: FastAPI: Advanced Middleware
/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
| Middleware | Behaviour and options |
|---|---|
|
Compresses responses when the request’s |
|
Rejects requests whose |
|
Redirects |
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)
Source: FastAPI: Advanced Middleware
['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:
-
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. -
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 |
|---|---|
|
The client’s IP address (a list when several proxies are chained). |
|
The scheme the client used, |
|
The |
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.
| Flag | Meaning |
|---|---|
|
Enable or disable interpreting forwarded headers. Enabled by default, but only for trusted connecting IPs. |
|
Comma-separated IP addresses, networks or literals (a UNIX socket path) allowed to set proxy headers; |
|
Sets the ASGI |
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"),
}
Source: FastAPI: Behind a Proxy
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/
| Server flags | Location of the 307 slash redirect |
Body of /items/ |
|---|---|---|
defaults |
|
|
|
|
|
|
|
|
|
|
What the table shows:
-
With a trusted caller the app sees
httpsand the real client address, sorequest.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 athttp://addresses. -
The redirect from
/itemsto/items/is the official example for this feature: without forwarded headers the redirect leaves the HTTPS site forhttp://. -
In this run, Uvicorn used the scheme and client address from the headers but kept the
Hostit was actually called with (127.0.0.1:810x), despite theX-Forwarded-Hostheader: Uvicorn’sProxyHeadersMiddlewaredocuments onlyX-Forwarded-ForandX-Forwarded-Proto. The proxy therefore has to forward the originalHostheader itself (Traefik does by default; Nginx needsproxy_set_header Host $host;), which is also what makesTrustedHostMiddlewarework behind it. The FastAPI page listsX-Forwarded-Hostamong the proxy headers; do not rely on it unless you have checked your Uvicorn version. -
fastapi runanduvicornaccept the same three flags; in Docker, set the environment variableFORWARDED_ALLOW_IPSinstead of editing the command, for exampleFORWARDED_ALLOW_IPS="*"on a private network. The official Dockerfile page suggests adding--proxy-headersto thefastapi runcommand 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"]
Source: FastAPI: Advanced Middleware
| Server flags and request | Status | Why |
|---|---|---|
|
|
The forwarded header is ignored, the app sees |
|
|
The trusted proxy says the client used HTTPS, so no redirect. |
same, |
|
A genuinely plain-HTTP client is redirected. |
same, |
|
|
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/docswould fetch/openapi.jsoninstead of/api/v1/openapi.json; -
request.url_for(…), redirects andLocationheaders would omit the prefix; -
the OpenAPI
serverslist 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-pathoption). -
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"]])
Source: FastAPI: Behind a Proxy
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_pathfor 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/appmatched the same route, because the router removes a leadingroot_pathfrom the path before matching. A proxy that forwards the full path therefore needs the sameroot_pathsetting and no application code changes. If it forwards the full path and you set noroot_path, the routes would have to be declared with the prefix (anAPIRouter(prefix="/api/v1")). -
url_for, the docs and OpenAPI followroot_path. The Swagger UI now asks for/api/v1/openapi.json; the sameroot_pathis prepended tooauth2-redirectand the ReDoc URL. -
servers. With aroot_path, FastAPI adds{"url": "/api/v1"}as the first server. If you supply your ownserverslist (for example staging and production, so Swagger UI can target either), theroot_pathentry is inserted at the beginning unless you passroot_path_in_servers=False. With noroot_paththeserverskey 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")}
Source: FastAPI: Behind a Proxy
# 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
stripPrefixmiddleware stores the removed prefix in anX-Forwarded-Prefixheader, 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-pathorFastAPI(root_path=…). -
Keep them in sync. The prefix configured in the proxy and the
root_pathin 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-pathand aFastAPI(root_path=…)to different values. -
Do not double the prefix. With a stripping proxy the routes and routers stay unprefixed. Adding
/api/v1to anAPIRouterand stripping it at the proxy would require/api/v1/api/v1/books. -
Mounted sub-applications work. When you
mounta sub-application while usingroot_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
Locationheaders are built fromroot_pathplus 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"
Source: Traefik: StripPrefix
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:
| Concern | What to do |
|---|---|
The app must know the request was HTTPS |
Run with |
Redirecting |
Prefer the proxy (an entry-point or router redirect on port 80). |
HSTS ( |
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 |
Host validation |
Forward the original |
Client IP |
Use |
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 |
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
CORSMiddlewarefirst" is the wrong instinct whenever other middleware can reject or redirect a request. -
CORS wildcard with credentials. Many tutorials set
allow_origins=["*"]withallow_credentials=Trueside 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-headersand trusted everything. Uvicorn’s default now trusts only127.0.0.1and::1and the proper switch is--forwarded-allow-ips; the FastAPI documentation still mentions--proxy-headersin the Docker page, which is the (default-on) enable flag, not the trust list. -
root_pathinstead ofopenapi_prefix. The oldopenapi_prefixargument was deprecated in favour ofroot_path;serversis generated from it (and omitted when there is none).root_path_in_servers=Falseswitches the automatic entry off. -
Prefix handling in routing. Current Starlette removes a leading
root_pathfrom the path before routing, so a proxy that forwards the unstripped path also works withroot_pathset. -
Dependency exit code and background tasks. Where a dependency with
yieldexits 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 withyield. -
Built-in middleware list. Starlette 1.x added
RequestBodyLimitMiddleware, an experimental OpenTelemetry middleware, and options such asGZipMiddleware(thread_minimum_size=, exclude_content_types=)andCORSMiddleware(allow_private_network=). The"*"method wildcard now includesQUERY. -
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: Originand the credentials rules. UseCORSMiddleware.
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 withRequestBodyLimitMiddlewareor the proxy, or use a pure ASGI middleware that wrapsreceiveand counts chunks. -
Slow work in middleware. It runs for every request, including
/docs, health checks and 404s; scope work withrequest.url.pathchecks or put it in a dependency. -
Exceptions in
call_next. In a run, an unhandledRuntimeErrorfrom a path operation was raised out ofcall_nextinside the middleware (the500is produced later, by the outerServerErrorMiddleware), so wrap the call intry/finallyif you must clean up or record metrics. -
State on the middleware instance. One instance serves all requests concurrently; mutating
selfper request is a race condition. -
CORSMiddlewareafter authentication in a middleware. If an inner layer rejects the preflight with401before CORS sees it, the browser reports a CORS error. CORS must be outside (added after) anything that can rejectOPTIONS. -
Authorization and CORS. A CORS error in the console can be a
500, a401or a proxy error with no CORS headers; look at the network tab for the real status. -
allow_origins=[""] with credentials, trailing slashes in origins,.inallow_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
HTTPSRedirectMiddlewarecan ping-pong when the scheme is not forwarded. -
Docs working locally but not behind the proxy. Almost always a missing
root_path; check theserversentry and theurl: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.
GZipMiddlewarecompresses each chunk it receives; for SSE it is excluded by default, and a proxy that buffers responses defeats streaming regardless.