Skip to content

CORS configuration

Severity: highApplies to: FastAPI 0.100+Applies to: Starlette 1.xFacts last verified 2026-08-13 against FastAPI 0.141.1 · Starlette 1.6.0

FastAPI ships no CORS headers by default. CORSMiddleware comes from Starlette and is re-exported as fastapi.middleware.cors.CORSMiddleware, so adding it is a deliberate act — there is no default policy to inherit and no default to get wrong.

What there is to get wrong is the shape of the options, because they are not all the same kind of thing.

the fix
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
max_age=600,
)
verify it workedRun this in: http response
Terminal window
# a preflight, which is where the policy is actually declared
curl -si -X OPTIONS https://api.example.com/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
| grep -i 'access-control-'

Check the echoed origin is exactly the one you sent, and that allow-methods and allow-headers list what you configured rather than *.

settings on this page
allow_originsoption

Origins permitted to read responses. The default is an empty tuple, so installing the middleware bare permits nothing — the permissive states are all things you type. Combined with allow_credentials=True, a ["*"] here stops behaving like a wildcard and starts reflecting the caller's Origin.

accepts
["https://app.example.com"] | ["*"]
default
()
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature; reproduced
allow_credentialsoption

Sends Access-Control-Allow-Credentials: true. This is the switch that changes what allow_origins=["*"] means — the pair is the control, neither half is safe to read alone.

accepts
True | False
default
False
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature; reproduced
allow_methodsoption

Methods allowed cross-origin. The default is GET only, which is more restrictive than most people assume — a cross-origin POST fails preflight until this is widened.

accepts
["GET", "POST"] | ["*"]
default
('GET',)
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature
allow_headersoption

Request headers allowed cross-origin. Empty by default; Starlette always permits the CORS-safelisted headers on top of whatever is listed here.

accepts
["Authorization", "Content-Type"] | ["*"]
default
()
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature
allow_origin_regexoption

Pattern alternative to allow_origins. A permissive pattern reflects exactly as a wildcard does — ".*" with credentials was reproduced returning the caller's own Origin — and it is easier to write one by accident here than in a list.

accepts
"https://.*\\.example\\.com" | None
default
None
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature; reproduced
allow_private_networkoption

Answers the Private Network Access preflight, letting a public page reach this app on a private address. Off by default, and it should stay off unless that is deliberately what the service is for.

accepts
True | False
default
False
set in
app.add_middleware(CORSMiddleware, ...)
since
present in Starlette 1.6.0
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature
max_ageoption

How long a browser may cache the preflight result. Relevant when tightening a policy — a stale preflight keeps the old answer alive for up to this long after you deploy the fix.

accepts
600 | any int, seconds
default
600
set in
app.add_middleware(CORSMiddleware, ...)
Read in: starlette 1.6.0 starlette/middleware/cors.py — CORSMiddleware.__init__ signature
if you see this
Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

No CORS header was sent at all — either the middleware is not installed, or the origin did not match the allow-list. Nothing failed on the server: the request was received and handled normally, and the browser discarded the response after it arrived, which is why the application log looks clean.

Read in: chromium third_party/blink/renderer/platform/loader/cors/cors_error_string.cc
Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

The OPTIONS preflight failed, not the request itself. The extra clause is the tell — it means the browser never sent the real request, so looking for it in your access log will find nothing.

Read in: chromium third_party/blink/renderer/platform/loader/cors/cors_error_string.cc

The origin wildcard is not like the others

Section titled “The origin wildcard is not like the others”

allow_methods=["*"] and allow_headers=["*"] are ordinary conveniences. Widening them lets a caller invoke more verbs and send more headers on a request that was already going to be allowed by the origin check.

allow_origins=["*"] is a different kind of statement: it decides who is allowed at all. It is the only one of the three that changes the set of sites that can read your responses, and it is the only one whose interaction with allow_credentials is dangerous — see CORS origin reflection, which is the reason that pairing has its own page.

Treat allow_methods and allow_headers as tidiness. Treat allow_origins as the control.

allow_credentials is what makes the policy matter

Section titled “allow_credentials is what makes the policy matter”

Without it, a cross-origin fetch sends no cookies and no Authorization header, so whatever the caller reads is what an anonymous client could have read anyway — which is often nothing interesting.

With it, the response the calling page reads is the authenticated one. Every consequence on this page and its threat page follows from that single flag, so it is worth being deliberate: turn it on because a browser client genuinely needs cookie auth, not because it appeared in a snippet.

max_age defaults to 600 seconds, sent as Access-Control-Max-Age. For that window the browser does not re-ask — it reuses the previous preflight result.

This produces a specific and very confusing failure: you tighten the policy, deploy, test in the browser you have been using all afternoon, and see the old behaviour. Nothing is wrong with the deployment; the browser has not asked again.

When testing policy changes, use curl — which never caches a preflight — or a fresh private window. Do not conclude anything from a browser that has spoken to the old build within the last ten minutes.

Routers and mounted apps do not inherit it

Section titled “Routers and mounted apps do not inherit it”

add_middleware applies to the application it is called on. A sub-application mounted with app.mount("/v2", other_app) runs other_app’s own middleware stack, so a CORS policy added to the parent does not cover it.

If you mount sub-applications, each one needs its own policy, and the verify step above needs running against a path inside each mount rather than only at the root.

before you ship this

Narrowing allow_headers is the one that breaks quietly. Anything your front end sends that is not on the list — a X-Request-Id, a tracing header injected by a client SDK, Content-Type: application/json on a request you thought was a simple GET — turns the request into a preflighted one and then fails the preflight.

The browser reports this as a CORS error on the endpoint, which sends people looking at the endpoint rather than at the header list.

Before narrowing, collect what is actually being sent:

Terminal window
# in devtools: Network -> the failing request -> Request Headers
# or from the preflight the browser already sent
curl -si -X OPTIONS https://api.example.com/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type,x-request-id' \
| grep -i 'access-control-allow-headers'

The response lists which of those you currently permit. Anything missing is what will break.