The browser console showed exactly what CORS errors always show — a request blocked for violating the same-origin policy — except the response headers, visible in the network tab, clearly included Access-Control-Allow-Origin: *. The header the browser wanted was right there. The browser rejected the request anyway.
The detail that's easy to miss in the network tab
Chrome's network inspector, by default, coalesces duplicate header names into a single display line — so Access-Control-Allow-Origin: * shown once in the UI can actually mean the header was sent twice by the server, and the browser is showing you a merged, deduplicated view rather than the literal wire response.
curl -s -D - https://api.example.com/data -o /dev/null | grep -i access-control
Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: https://app.example.com
Two separate headers, both valid individually, sent by two different layers that each thought they were the one responsible for CORS: our nginx reverse proxy had a blanket add_header Access-Control-Allow-Origin *; for general API access, and the application server behind it independently set a specific origin for authenticated routes. Neither config was wrong on its own. Together, they produced a response with the header appearing twice — and per the Fetch spec, a response with multiple Access-Control-Allow-Origin values is treated as invalid, so the browser blocks the request rather than guessing which one you meant.
Why this is worse than a missing header
A missing CORS header fails immediately, obviously, the same way every time. A duplicate header fails in a way that looks, from the response body alone, like the header is present and correct — because it is present, twice, which is precisely the state that trips the spec's validation. Every piece of evidence you'd normally check says "this should work," and it still doesn't.
The fix
Removed the blanket nginx header and let the application server be the single source of truth for CORS decisions, since it's the layer that actually knows which origins should be allowed for which routes:
# removed this — the app now owns CORS entirely
# add_header Access-Control-Allow-Origin *;
location /api/ {
proxy_pass http://app_upstream;
# no CORS headers added here
}
One layer, one header, no duplication possible by construction rather than by discipline.
Where this kind of overlap tends to hide
This class of bug — two layers of infrastructure each independently and correctly configured, producing an incorrect result only in combination — is exactly why we keep our ingress configuration minimal on Krova: the managed HTTPS ingress terminates TLS and forwards to your Cube without injecting application-level headers like CORS on your behalf, so there's exactly one place — your own app or reverse proxy — that owns those decisions, not two competing ones split across a platform layer and your own config without either side being aware of the other. I run Krova, so take the specific setup as informed rather than neutral, but the debugging lesson generalizes: when a header looks right in the browser but the request still fails, check for duplicates on the wire, not just presence.
What I'd check first
curl -sD - against the failing endpoint, piped through grep -i access-control, before trusting anything the browser's network tab shows you. If you see the same header name more than once, that's the entire bug — find both places setting it and remove all but one.
Top comments (0)