Skip to content

CORS origin reflection

Severity: criticalApplies to: FastAPI 0.100+Applies to: Starlette 0.30+Applies to: Starlette 1.xFacts last verified 2026-07-29 against FastAPI 0.141.0 · Starlette 1.3.1

This is the one to check first, because the dangerous configuration is the one people arrive at by fixing a browser error, and because nothing about it looks wrong.

The configuration is this, and it appears in a great many tutorials:

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

Read literally it says “allow every origin”. What it actually does is allow every origin with credentials — which the CORS specification forbids expressing as a wildcard, and which Starlette therefore expresses a different way.

CORSMiddleware decides up front whether it can use a literal *:

preflight_explicit_allow_origin = not allow_all_origins or allow_credentials

With allow_origins=["*"], allow_all_origins is true — but allow_credentials is also true, so the flag flips and the wildcard is taken off the table. Then, per response:

if self.allow_all_origins and self.allow_credentials:
self.allow_explicit_origin(headers, origin)

allow_explicit_origin sets Access-Control-Allow-Origin to the origin from the request, and adds Vary: Origin.

So the header is never *. It is whatever the caller said it was.

Both configurations, same request, Origin: https://evil.example:

Configuration Access-Control-Allow-Origin Access-Control-Allow-Credentials
allow_origins=["*"] * absent
allow_origins=["*"], allow_credentials=True https://evil.example true
allow_origin_regex=".*", allow_credentials=True https://evil.example true
allow_origins=["https://app.example.com"], allow_credentials=True absent true

The first row is the safe one, and it is safe by accident: browsers refuse to attach credentials to a wildcard, so a wildcard-only policy fails closed. The second row is the same configuration with one more line, and it fails open to every origin on the internet.

The fourth row is what correct looks like — a non-matching origin gets no Access-Control-Allow-Origin at all, so the browser withholds the response.

if you see this
Access to fetch at 'https://api.example.com/me' from origin 'https://app.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

The browser refusing the wildcard because credentials were attached. This message is the one that starts the whole problem — it appears first, and adding allow_credentials=True is the change that makes it go away by switching the server to reflection.

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

The consequence is not “an unauthorised request runs”. Requests already run; CORS was never what stopped them. The consequence is that any page a logged-in user visits can call your API with their cookies and read the response.

That covers session data, account details, anything the user’s own credentials reach. It requires no compromise of your infrastructure and no CVE — a visitor with a session loading an unrelated page is the entire attack.

The reason it earns a threat page rather than a footnote is the path people take to reach it. The wildcard alone produces a browser error the moment cookies are involved. That error names credentials, so allow_credentials=True is the obvious thing to add. The error disappears, the feature works, and the policy is now maximally permissive. A configuration that arrives by fixing an error message is one nobody re-reads.

the fix
from fastapi.middleware.cors import CORSMiddleware
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # never ["*"] alongside credentials
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)

An explicit list. Not a regex — see below — and not the wildcard.

verify it workedRun this in: http response
Terminal window
curl -sI -H 'Origin: https://evil.example' https://api.example.com/me \
| grep -i 'access-control-allow-'

The correct result is no Access-Control-Allow-Origin line at all. If the response echoes https://evil.example back, the API is reflecting.

Send a second request with a real origin from your allow-list to confirm you have not simply broken CORS altogether:

Terminal window
curl -sI -H 'Origin: https://app.example.com' https://api.example.com/me \
| grep -i 'access-control-allow-'

That one should return your origin, exactly.

allow_origin_regex is where people go after an allow-list becomes inconvenient, and it reflects on a match exactly as the wildcard-plus-credentials path does. The table above includes allow_origin_regex=".*" for that reason: it behaves identically.

Anchoring matters more than it looks. A pattern written to match your subdomains will match hostnames you did not intend unless it is anchored at both ends — .example\.com without a terminating anchor matches app.example.com.evil.test.

If you need a pattern, terminate it: r"https://[a-z0-9-]+\.example\.com$".

Worth stating plainly, because the fix above can read like one. A restrictive policy does not stop a request from executing. Your endpoint receives it, runs it, and commits whatever it commits; CORS only governs whether the calling page is permitted to read the response.

So this page closes a read-access hole in browsers. It is not authentication, and it is not a reason to leave an endpoint public.

before you ship this

Replacing a wildcard with an allow-list breaks every front end on a hostname that is not in the list — preview deployments, staging, a mobile web wrapper, a partner embed, and localhost during development.

These fail in the visitor’s browser console, not in your logs, so nothing alerts and nobody reports it except users.

Enumerate the hostnames first. Read them out of your existing access logs by looking at the Origin header rather than guessing:

Terminal window
# the origins currently calling you, most frequent first
grep -oiE 'origin: [^"]+' access.log | sort | uniq -c | sort -rn | head -20

Keep development origins in a separate list selected by environment, so localhost never ships in the production allow-list.

  • CORS configuration — the full set of options and what each one does
  • Proxy headers — the other setting where a permissive value is the documented escape hatch