Blocked by CORS Policy: Stop Reaching for Access-Control-Allow-Origin: *
The front end is on app.example.com, the API is on api.example.com, and the browser console has gone red. You search the error, and the first ten results all say the same thing: add Access-Control-Allow-Origin: *. You add it. It works. You ship.
Then someone adds login, cookies start travelling with the requests, and the same endpoint breaks again with a different CORS error. So you search again, and this time the answer is to echo back whatever Origin the request sent. That also works. That one is worse than the wildcard, and almost nobody says so.
Here is the thesis: CORS is not a bug in your API and it is not a thing you turn off. It is you telling the browser which other websites are allowed to read your responses. * means "every website on the internet, and I promise this data is public". Reflecting the request's Origin means the same thing, except you have also enabled it for authenticated requests, which is a design the specification deliberately forbids you from writing directly.
keep an explicit allowlist on the server, compare the incoming Origin against it, and echo back only a value that matched.
- Wildcard
*is fine for genuinely public, credential-free endpoints: CDN assets, open data, public read APIs with no cookies and noAuthorizationheader. - Never combine
Access-Control-Allow-Credentials: truewith*. The spec forbids it and browsers reject it. - Never reflect
req.headers.originwithout checking it against a list. With credentials enabled that is a full cross-site account-read vulnerability. - Always send
Vary: Originwhen the header value depends on the request, or a cache will serve one site's permission to another. - The preflight
OPTIONSresponse needs the same allowlist logic, plusAccess-Control-Allow-MethodsandAccess-Control-Allow-Headers, and must return a 2xx.
The exact errors
Chrome and Edge, the one everybody searches:
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.
The preflight variant, which means the OPTIONS request failed before your real request was ever sent:
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.
And the one you get the moment credentials enter the picture, in Firefox's wording:
Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'
Chrome says the same thing at more length, complaining that the value must not be the wildcard when the request's credentials mode is include. This is the fork in the road. What you do next determines whether you have a working API or a security incident with a deadline.
Why the browser is doing this
An origin is the triple of scheme, host and port. https://app.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:8000, which is why this bites on day one of every project.
The same-origin policy says JavaScript on one origin cannot read responses from another. CORS is the opt-out: the server attaches headers saying which origins are permitted, and the browser enforces the answer. Two consequences people miss:
- The request usually still reaches your server. For a simple request, the browser sends it, your handler runs, your database is written to, and then the browser refuses to hand the response body to the calling script. CORS is not an access control on your API. It is a rule about who can read the reply.
- It is a browser rule only. curl, Postman, your mobile app and any attacker's script running outside a browser ignore CORS entirely. If your endpoint needs protecting, it needs authentication and authorisation. CORS is not that.
A request is preflighted when it goes beyond the simple set: any method other than GET, HEAD or POST; a Content-Type other than application/x-www-form-urlencoded, multipart/form-data or text/plain; or any custom header. Sending JSON with Content-Type: application/json preflights, which is why nearly every real API triggers it.
The dangerous fix nobody warns you about
When the wildcard stops working because you need cookies, the internet's next suggestion is this:
// DO NOT DO THIS
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin)
res.header('Access-Control-Allow-Credentials', 'true')
next()
})
Read it as a policy statement rather than as code and it says: any website that asks is allowed to read authenticated responses from this API. It is functionally a wildcard, except the wildcard is legal with credentials because you built it out of a variable.
The attack is three lines. A user who is logged into your app opens an unrelated page:
// on https://totally-not-evil.example
const r = await fetch('https://api.example.com/me/invoices', { credentials: 'include' })
console.log(await r.json()) // exfiltrate
The browser attaches your user's session cookie because it is a request to your domain. Your server reflects https://totally-not-evil.example into Access-Control-Allow-Origin, sets Allow-Credentials: true, and the browser dutifully hands the JSON to the attacker's script. No XSS on your domain required. No CSRF token helps, because this is a read.
SameSite=Lax cookies, which are the default in current browsers, blunt the cookie half of this. They do not help at all when the credential is an Authorization header, or when you have set SameSite=None because your app is genuinely cross-site, which is exactly the situation that made you reach for reflection. Treat SameSite as a second layer, never as the reason it is fine.
The pattern that is actually correct
Step 1: Write down the allowlist
Not a regex. A list. Regexes for origins are a reliable source of holes, because https://app.example.com.evil.net matches more patterns than people expect.
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
'http://localhost:3000',
])
Keep localhost entries behind an environment check so they do not reach production.
Step 2: Echo only a matched value, and set Vary
app.use((req, res, next) => {
const origin = req.headers.origin
res.setHeader('Vary', 'Origin')
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '600')
return res.sendStatus(204)
}
next()
})
Three details that are doing real work:
- No header at all on a non-match. Do not send
Access-Control-Allow-Origin: nulland do not return a 403 page with CORS headers on it. Absence is the correct answer, and the browser will block it. Vary: Originis set unconditionally, including on the non-match path. If you only set it when you match, a shared cache can store the no-header version and replay it to your legitimate front end.- The OPTIONS branch runs before authentication. Mount this middleware above your auth middleware. A preflight carries no cookies your auth layer will accept, so an auth check that runs first returns 401 and the browser reports it as a missing CORS header.
Step 3: Or do it at the edge, in nginx
If your API sits behind nginx, keeping CORS in one place beats scattering it across services:
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://app_upstream;
}
}
An empty $cors_origin makes nginx omit the header entirely, which is the behaviour you want for an origin that is not on the list. The always flag matters: without it the headers are dropped on error responses, so a 500 from your upstream turns into a confusing CORS error in the console instead of the 500 it is. If nginx is new territory, the reverse proxy config I actually run covers the surrounding setup.
Step 4: Pick the right policy per endpoint
| Endpoint | Allow-Origin | Allow-Credentials |
|---|---|---|
| Public CDN assets, fonts, open data | * | absent |
| Public read API, no auth ever | * | absent |
| Your own SPA calling your own API with cookies | matched origin only | true |
| Partner API with bearer tokens | matched origin only | absent, tokens are sent explicitly |
| Internal admin API | matched origin only | true |
Verification
Test it with curl, not with your app. Positive case first:
curl -i -X OPTIONS https://api.example.com/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type,authorization"
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-credentials: true
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE
access-control-allow-headers: Content-Type,Authorization
access-control-max-age: 600
vary: Origin
Now the test that matters, the negative case:
curl -i -X OPTIONS https://api.example.com/orders \
-H "Origin: https://totally-not-evil.example" \
-H "Access-Control-Request-Method: PUT" | grep -i "access-control-allow-origin"
Expected output: nothing. No line at all. If that command prints access-control-allow-origin: https://totally-not-evil.example, you are reflecting, and if allow-credentials: true is anywhere in the response you have the vulnerability described above and it is a today problem, not a backlog problem.
Finally, confirm the real response carries the headers too, since a passing preflight followed by a bare 200 still fails in the browser:
curl -sI https://api.example.com/orders -H "Origin: https://app.example.com" | grep -i "access-control\|vary"
What people get wrong
"Set it to *, it is just a dev thing." Wildcards ship. They ship on internal admin APIs, on endpoints returning customer records, on anything the person fixing the error at 11pm was not thinking about. If the endpoint could ever return data specific to a logged-in user, * is wrong even before credentials are involved, because it invites the reflection fix later.
"Just reflect the Origin, it is the same thing." It is the same thing, and that is the problem. The wildcard at least fails loudly when you add credentials. Reflection fails silently, in your users' favour right up until it does not.
"Allow null so file:// and sandboxed iframes work." Any sandboxed iframe on any site can produce Origin: null. Allowlisting it is allowlisting the internet with extra steps.
"Launch Chrome with --disable-web-security." This turns a real integration problem into a local illusion, and the bug reappears in staging with less time to fix it. If you want cross-origin friction gone in development, proxy instead: Next.js rewrites, Vite's server.proxy and CRA's proxy field all make the API same-origin for the browser. That is a legitimate technique, not a workaround, and it is often the right production answer too.
"CORS protects my API." It does not. It is enforced only by browsers, on the response, after your handler has already run. Authentication and authorisation protect your API. If you are still designing that layer, Six API Design Mistakes covers the ones that cost the most later.
When it is still broken
- Duplicate headers. If both your application and your reverse proxy add CORS headers, the browser sees two values and refuses: the message complains that the header contains multiple values but only one is allowed. Pick one layer and remove the other.
curl -Ishows both. - The preflight is not 2xx. Framework routers frequently have no
OPTIONSroute and return 404 or 405. Check the status code of the OPTIONS request directly, before reading anything into the CORS message. - A header is missing from Allow-Headers. Adding
X-Request-IdorX-Tenanton the client silently breaks the preflight until the server lists it. The browser tells you which one it wanted in the request'sAccess-Control-Request-Headers. - A cache in front of you. Without
Vary: Origin, a CDN or shared proxy caches the response including itsAccess-Control-Allow-Originand serves it to a different origin. The symptom is an error that comes and goes depending on which point of presence you hit, which is easy to blame on flaky networks when you are testing from Nairobi against an edge in Frankfurt. - You need a response header the client cannot see. JavaScript can only read a small default set of response headers cross-origin. Anything else, including pagination counts and rate-limit headers, requires
Access-Control-Expose-Headers. This is not an error, just a value that is mysteriouslynull.
The rule I hold to: every value in Access-Control-Allow-Origin should be a string a human deliberately wrote down somewhere, not a string that arrived in a request. Once you accept that, the rest of CORS is bookkeeping.
Frequently asked questions
- Why does Access-Control-Allow-Origin: * stop working when I send cookies?
- The CORS specification forbids combining the wildcard with credentials, and browsers enforce it. Firefox reports it as 'Credential is not supported if the CORS header Access-Control-Allow-Origin is *', and Chrome says the value must not be the wildcard when the request's credentials mode is include. The fix is to compare the incoming Origin against an explicit allowlist on the server and echo back only a value that matched.
- Is reflecting the request's Origin header back a safe way to fix CORS?
- No, it is the most dangerous common fix. Echoing req.headers.origin without an allowlist check, together with Access-Control-Allow-Credentials: true, tells the browser that any website may read authenticated responses from your API. A page a logged-in user visits can then fetch your endpoints with credentials included and read the JSON, with no XSS on your domain and no CSRF token able to stop it, because it is a read.
- Why do I need Vary: Origin on CORS responses?
- Because the Access-Control-Allow-Origin value depends on the request's Origin header, so a shared cache or CDN that ignores that dependency will store one origin's response and serve it to another. The symptom is a CORS error that appears and disappears depending on which cache node you hit. Set Vary: Origin on every response, including ones where no allow-origin header is sent.
- Does CORS protect my API from attackers?
- No. CORS is enforced only by browsers, and only on the response, after your handler has already run and any side effects have happened. curl, Postman, mobile apps and server-side scripts ignore it entirely. Protecting an API is the job of authentication and authorisation; CORS only decides which other websites' JavaScript may read the reply.