The CORS Header Is Present Twice and That Is Why It Fails
You added the header at the proxy, your framework was already sending it, and the browser now rejects both. Duplicates are invalid, not additive.
A frontend that had been calling its API happily for months started failing every request with a CORS error immediately after an infrastructure change that touched nothing in the application.
The error said the header was missing. curl -I showed the header was present. Both were true, in a sense, and the reason is one of the least intuitive rules in the whole specification.
The short answer
Access-Control-Allow-Origin must appear exactly once. If two layers both add it, the browser sees two values, treats the response as invalid, and reports it as though the header were absent.
Duplicated CORS headers are not additive and they do not take the first or last value. They fail. Remove the header from one layer and keep it in exactly one place.
Tested on Chrome 133, Firefox 135, nginx 1.27.
Confirming it in one command
curl -I folds some headers in its output, which is precisely why this is so easy to miss. Ask for the raw response instead.
curl -sS -D - -o /dev/null \
-H "Origin: https://app.example.com" \
https://api.example.com/v1/items | grep -i access-control
access-control-allow-origin: https://app.example.com
access-control-allow-origin: *
Two lines. That is the bug, complete, and Chrome's console message describes it accurately if you read past the first sentence:
The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, *', but only one is allowed.
That message is much better than the generic "no Access-Control-Allow-Origin header is present", which is what you get when the preflight fails for a different reason. Distinguishing the two saves you looking in the wrong place.
Cause one: the proxy and the application both send it
The overwhelmingly common case, and it happens because the two changes are made months apart by different people.
The application has CORS middleware:
app.use(cors({ origin: 'https://app.example.com', credentials: true }))
And the infrastructure adds it too, either in nginx:
add_header Access-Control-Allow-Origin *;
or as a managed proxy setting. Most platforms offer CORS configuration at the edge because it saves people implementing it per service. On Krova Cloud it sits alongside the header override and IP rules at proxy level, which is convenient right up until the application is already doing it, at which point you get exactly this.
Pick one layer. My preference is the application, because the allowed origin is usually logic rather than infrastructure: it varies by environment, and sometimes by tenant. But the proxy is a perfectly good choice if your origin list is static, and consistency across services matters more than which one you pick.
The nginx add_header behaviour has an extra trap worth knowing. add_header in a location block replaces, rather than supplements, add_header directives from the enclosing server block. So headers you thought were global silently vanish inside one location, and people respond by adding them back in every location, which is how you end up with duplicates once a proxy level setting appears too.
Cause two: wildcard with credentials
If your request sends cookies or an Authorization header, Access-Control-Allow-Origin: * is rejected outright. The specification forbids credentialed requests against a wildcard origin.
// wrong, when credentials: 'include' is used on the client
res.setHeader('Access-Control-Allow-Origin', '*')
// correct: echo the specific origin, after validating it
const allowed = new Set(['https://app.example.com', 'https://admin.example.com'])
const origin = req.headers.origin
if (allowed.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Vary', 'Origin')
}
The Vary: Origin line is not optional. Without it, a CDN or any shared cache will serve a response containing one origin's allow header to a different origin, producing a failure that only appears for some users and only sometimes. That is a genuinely miserable bug to chase, and one header prevents it.
Never reflect the origin without checking it against an allowlist. Echoing whatever arrives is equivalent to a wildcard that also works with credentials, which is worse than the thing the specification was trying to stop.
Cause three: the preflight never reaches your code
For anything beyond a simple request, the browser sends an OPTIONS request first. If that returns a 401, a 404 or a redirect, the real request is never sent.
curl -sS -D - -o /dev/null -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization" \
https://api.example.com/v1/items
Look for 204 or 200, plus Access-Control-Allow-Methods and Access-Control-Allow-Headers covering what you asked for.
Common ways this breaks:
Auth middleware runs before CORS. The OPTIONS request carries no credentials by design, so your authentication rejects it with a 401 and the browser reports a CORS failure. Mount CORS handling before authentication, always.
The route only accepts POST. Many routers will not match OPTIONS unless you register it. Frameworks with CORS middleware usually handle this; hand rolled routing frequently does not.
A redirect. Preflights do not follow redirects. If OPTIONS on /v1/items returns a 301 to /v1/items/, the preflight fails. Trailing slash mismatches cause a surprising share of these.
Cause four: the header you need is not exposed
CORS succeeds, the request completes, and JavaScript cannot read a response header. By default only a short list of headers is readable from script.
Access-Control-Expose-Headers: X-Request-Id, X-Total-Count, X-RateLimit-Remaining
This is not really an error, it is a default that people do not know about until pagination stops working.
Prevention
Own CORS in exactly one layer and write it in the runbook. This is the whole post. Every duplicate header I have seen came from two teams solving the same problem in two places without knowing.
Grep for duplicates in CI. A smoke test that counts access-control-allow-origin lines in a real response and fails above one catches this before a user does.
n=$(curl -sS -D - -o /dev/null -H "Origin: https://app.example.com" "$API" \
| grep -ci '^access-control-allow-origin:')
[ "$n" -eq 1 ] || { echo "CORS header count: $n"; exit 1; }
Always send Vary: Origin when the value is dynamic.
Test preflights explicitly. A curl -X OPTIONS in your integration tests, with the same headers the browser will send.
Read the console message to the end. "Contains multiple values" and "no header is present" are different diagnoses, and the browser is telling you which one you have. Like most errors that seem to contradict the evidence, the message was accurate and my reading of it was not.