CORS origin reflection
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.
What the middleware does with that pair
Section titled “What the middleware does with that pair”CORSMiddleware decides up front whether it can use a literal *:
preflight_explicit_allow_origin = not allow_all_origins or allow_credentialsWith 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.
Observed
Section titled “Observed”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.
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.ccWhy it is rated critical
Section titled “Why it is rated critical”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.
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.
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:
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.
The regex escape hatch has the same hole
Section titled “The regex escape hatch has the same hole”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$".
CORS is not access control
Section titled “CORS is not access control”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.
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:
# the origins currently calling you, most frequent firstgrep -oiE 'origin: [^"]+' access.log | sort | uniq -c | sort -rn | head -20Keep development origins in a separate list selected by environment, so localhost
never ships in the production allow-list.
Related
Section titled “Related”- 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