CORS configuration
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.
from fastapi import FastAPIfrom 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,)# a preflight, which is where the policy is actually declaredcurl -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 *.
allow_originsoptionOrigins 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, ...)
allow_credentialsoptionSends 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, ...)
allow_methodsoptionMethods 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, ...)
allow_headersoptionRequest 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, ...)
allow_origin_regexoptionPattern 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, ...)
allow_private_networkoptionAnswers 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
max_ageoptionHow 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, ...)
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.ccAccess 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.ccThe 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.
Preflight caching will hide your fix
Section titled “Preflight caching will hide your fix”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.
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:
# in devtools: Network -> the failing request -> Request Headers# or from the preflight the browser already sentcurl -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.
Related
Section titled “Related”- CORS origin reflection — the wildcard-plus-credentials case, and why it is critical
- Trusted hosts — the other header-driven check that defaults to allowing everything