CORS Middleware Configuration in FastAPI
Key takeaways:
- CORS is enforced by the browser. A blocked request almost always got a
200from your server. CORSMiddlewareanswersOPTIONSpreflights itself, before routing — you never write anOPTIONSroute.allow_credentials=Truewithallow_origins=["*"]is invalid per spec. Starlette does not error; it echoes the caller's origin, effectively trusting everyone.- A disallowed origin gets a response with no
access-control-allow-originheader. That absence is the error. - Add
CORSMiddlewarelast so it ends up outermost and its headers survive errors from inner layers.
This page is part of middleware implementation, and unlike most middleware topics it is mostly about a component you should not write yourself — only configure correctly.
The Problem This Solves
The browser console says No 'Access-Control-Allow-Origin' header is present on the requested resource. You add CORSMiddleware, it still fails. You add allow_origins=["*"], the error changes to something about credentials. You add allow_credentials=True, and now it fails differently.
Every step of that loop is guesswork, because the one thing you cannot see from the browser is what the server actually sent. This page shows the real headers for each configuration so you can compare them against yours.
Why It Happens: the Browser Is the Enforcer
Cross-origin restrictions are a browser policy. Your server has no idea whether a request came from a page on another origin unless it reads the Origin header, and nothing forces it to act on that. CORSMiddleware does two jobs:
It answers preflights. For any request the browser considers non-simple — a PUT, a Content-Type: application/json body, a custom header — the browser first sends an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers. Starlette's middleware intercepts that before the router sees it and replies from configuration alone. This is why CORS works on routes that only declare @app.post, and why writing your own OPTIONS handler is unnecessary.
It annotates real responses. For a request with an Origin header, it adds access-control-allow-origin (and friends) to whatever the application returned.
Crucially, when the origin is not allowed, the middleware does not block anything. The request runs, your route executes, the response goes out — just without the header. The browser then refuses to expose that response to JavaScript. Your server-side logs show a 200. This is the single biggest source of confusion in CORS debugging, and it is why "is it a FastAPI bug?" is almost always answered no.
The Configuration
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# Added last so it ends up outermost: its headers then survive inner middleware errors.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["authorization", "content-type"],
max_age=600,
)
Origins are matched as exact strings including scheme and port. https://app.example.com does not match https://app.example.com:8443, http://app.example.com, or https://www.app.example.com. For a set of subdomains use allow_origin_regex instead, and anchor it — an unanchored pattern like https://.*\.example\.com will happily match https://evil.com/?x=https://a.example.com in some formulations.
Proving It: Real Headers for Every Case
The verification harness cannot send an Origin header directly, so this example drives three differently-configured applications through an in-process ASGI transport and returns the real CORS headers each one produced.
"""Real CORS headers: preflight, disallowed origin, and allow_credentials with allow_origins=['*']."""
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
def build(**cors_kwargs) -> FastAPI:
target = FastAPI()
target.add_middleware(CORSMiddleware, **cors_kwargs)
@target.get("/api/data")
async def data():
return {"ok": True}
@target.post("/api/data")
async def create():
return {"created": True}
return target
strict = build(
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["authorization", "content-type"],
max_age=600,
)
wildcard_with_credentials = build(
allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]
)
wildcard_no_credentials = build(
allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"]
)
async def probe(target: FastAPI, method: str, headers: dict) -> dict:
transport = httpx.ASGITransport(app=target)
async with httpx.AsyncClient(transport=transport, base_url="http://api.example.com") as c:
response = await c.request(method, "/api/data", headers=headers)
return {
"status": response.status_code,
"cors_headers": {
k: v for k, v in sorted(response.headers.items())
if k.startswith("access-control-") or k == "vary"
},
}
PREFLIGHT = {
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
}
A preflight that succeeds
$ GET /preflight-allowed
200 OK
{
"status": 200,
"cors_headers": {
"access-control-allow-credentials": "true",
"access-control-allow-headers": "Accept, Accept-Language, Content-Language, Content-Type, authorization, content-type",
"access-control-allow-methods": "GET, POST",
"access-control-allow-origin": "https://app.example.com",
"access-control-max-age": "600",
"vary": "Origin"
}
}
The echoed origin is the exact string from allow_origins, and vary: Origin is present so caches do not serve one origin's response to another. Note that allow-headers contains four entries you did not configure — Accept, Accept-Language, Content-Language, Content-Type are the CORS-safelisted request headers, which Starlette always includes.
A preflight from a disallowed origin
$ GET /preflight-bad-origin
200 OK
{
"status": 400,
"cors_headers": {
"access-control-allow-credentials": "true",
"access-control-allow-headers": "Accept, Accept-Language, Content-Language, Content-Type, authorization, content-type",
"access-control-allow-methods": "GET, POST",
"access-control-max-age": "600",
"vary": "Origin"
}
}
Status 400, and — the decisive detail — no access-control-allow-origin header. Everything else is still there, which is why eyeballing the response in DevTools can mislead: the presence of several access-control-* headers looks like CORS is working. It is the absence of exactly one that matters.
A preflight asking for a header you did not allow
$ GET /preflight-bad-header
200 OK
{
"status": 400,
"cors_headers": {
"access-control-allow-credentials": "true",
"access-control-allow-headers": "Accept, Accept-Language, Content-Language, Content-Type, authorization, content-type",
"access-control-allow-methods": "GET, POST",
"access-control-allow-origin": "https://app.example.com",
"access-control-max-age": "600",
"vary": "Origin"
}
}
Here the origin is allowed, so access-control-allow-origin is present, but the status is still 400 because x-not-allowed was requested and is not in allow_headers. Starlette returns a plain-text body naming the reason — Disallowed CORS headers here, and Disallowed CORS origin in the previous case. If you have a client sending x-request-id or x-api-version and never added it to allow_headers, this is your failure.
The real request, allowed and disallowed
$ GET /simple-allowed
200 OK
{
"status": 200,
"cors_headers": {
"access-control-allow-credentials": "true",
"access-control-allow-origin": "https://app.example.com",
"vary": "Origin"
}
}
$ GET /simple-bad-origin
200 OK
{
"status": 200,
"cors_headers": {
"access-control-allow-credentials": "true"
}
}
$ GET /no-origin-header
200 OK
{
"status": 200,
"cors_headers": {}
}
The disallowed origin got a 200. The route ran. The database was queried. Only the header is missing, and the browser discards the response. This is exactly the situation where a developer sees a successful request in the server log and concludes FastAPI is fine — and it is fine; the configuration is not.
The last case shows that a request with no Origin header at all gets no CORS headers. Server-to-server clients and curl are unaffected by any of this.
The wildcard-plus-credentials trap
$ GET /wildcard-with-credentials
200 OK
{
"status": 200,
"cors_headers": {
"access-control-allow-credentials": "true",
"access-control-allow-origin": "https://anything.example.com",
"vary": "Origin"
}
}
$ GET /wildcard-no-credentials
200 OK
{
"status": 200,
"cors_headers": {
"access-control-allow-origin": "*"
}
}
This is the result worth internalising. With allow_origins=["*"] and allow_credentials=False, Starlette sends a literal * and no vary header — correct, cacheable, and unable to carry cookies.
With allow_origins=["*"] and allow_credentials=True, Starlette does not raise an error and does not send *. It echoes back whatever origin asked — here https://anything.example.com, an origin nobody configured — alongside allow-credentials: true. The CORS specification forbids * with credentials, so Starlette's accommodation is to satisfy the browser by reflecting the origin. The practical effect is that any website on the internet can make credentialed requests to your API with the user's cookies attached and read the response. It is not a crash, it is not a warning, and it will pass every test you write. Never combine the two. Enumerate your origins, or use allow_origin_regex.
Verification
Reproduce a preflight against a running server without a browser:
curl -i -X OPTIONS https://api.example.com/api/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
Then check three things in order: the status is 200, access-control-allow-origin is present and exactly matches the Origin you sent, and access-control-allow-headers covers every header your client sends. If all three hold and the browser still refuses, the request is not reaching your application at all — a proxy, load balancer or CDN in front of it is stripping headers or answering the OPTIONS itself.
As a regression test:
def test_preflight_allows_the_spa(client):
r = client.options(
"/api/data",
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization",
},
)
assert r.headers["access-control-allow-origin"] == "https://app.example.com"
def test_unknown_origin_gets_no_allow_header(client):
r = client.get("/api/data", headers={"Origin": "https://evil.example.com"})
assert "access-control-allow-origin" not in r.headers
Trade-Offs and When Not To
CORS is not authorization. It restricts which web pages may read your responses. It does nothing about a server, a script, or a mobile app calling your API. Access control belongs in a dependency, per the reasoning in middleware vs dependencies.
Ordering matters more than it looks. If an inner middleware raises and an outer layer converts it to a 500, that error response only carries CORS headers if CORSMiddleware is outside the failure. Since the last middleware added is the outermost — the rule demonstrated in middleware execution order — add CORS last. Otherwise your frontend reports a CORS error when the real problem is a 500, and you debug the wrong thing.
Different origins per environment belong in configuration. Hard-coding localhost:3000 next to your production origin means shipping a permanent local-development hole. Drive allow_origins from settings, as in secrets and env files per environment.
max_age is a cache, so it is also a delay. A long max_age avoids a preflight per request but means an origin or header change takes that long to take effect in browsers that already cached the answer. Browsers cap it well below most configured values anyway.
If you serve the SPA from the same origin, you need none of this. Putting the API behind /api on the same host removes the cross-origin condition entirely, which is strictly simpler and strictly safer than any configuration on this page.
FAQ
Why does my browser report a CORS error when curl works fine?
Because CORS is enforced by the browser, not the server. curl ignores the headers entirely, so a request that succeeds there can still be blocked in the browser. The server almost always returned 200; the browser refused to hand the response to your JavaScript.
Can I use allow_credentials=True with allow_origins="*"? The browser will reject it, because the CORS specification forbids a literal asterisk with credentials. Starlette does not raise an error for this combination; it echoes the requesting origin back instead, which quietly makes every origin a trusted one. List your origins explicitly.
Why does my preflight OPTIONS request fail?
Usually because the requested method or header is not in allow_methods or allow_headers. Starlette returns 400 with a plain-text reason such as Disallowed CORS headers, and it does not emit an allow-origin header for a disallowed origin, which is what the browser reports.
Do I need to add an OPTIONS route to handle preflight?
No. CORSMiddleware intercepts preflight requests before routing and answers them itself, which is why it works for routes that only declare GET or POST. Adding your own OPTIONS handler is unnecessary and can conflict.
Where should CORSMiddleware sit in my middleware stack?
Effectively outermost, so preflight responses and error responses from inner middleware still carry CORS headers. Since the last middleware added is the outermost, add CORSMiddleware last.
Why can my JavaScript not read a custom response header?
Response headers other than the safelisted few are hidden from script unless you name them in expose_headers. A correlation ID returned by your tracing middleware is invisible to the frontend until you add expose_headers=["x-request-id"].
Related Reading
- Up to the section: Middleware Implementation.
- Why CORS must be added last: Middleware Execution Order.
- Where access control actually belongs: Middleware vs Dependencies: When to Use Which.
- Per-environment origin lists: Secrets and Env Files per Environment.
- Exposing your trace header to the browser: Implementing Custom Middleware for Request Tracing.