Fixing Duplicate Access-Control-Allow-Origin Headers
The browser console shows this, and the request is blocked despite the origin being allowed:
Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: The
'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.
Root Cause
Two layers in your stack are each adding Access-Control-Allow-Origin. When both a reverse proxy (or load balancer) and the application emit the header, the response carries it twice. HTTP concatenates repeated same-name headers into a single comma-separated value, so the browser receives Access-Control-Allow-Origin: https://app.example.com, https://app.example.com. The WHATWG Fetch Standard’s CORS check (§3.2) requires the header to be a single origin (or *), compared byte-for-byte against the request’s origin. A comma-joined list — even of two identical values — is not a valid single origin, so the check fails and the browser blocks the response.
This page is part of Troubleshooting CORS at the Proxy Layer, which covers the broader family of intermediary-induced CORS failures.
Prerequisite State
- You can reach the application origin directly (internal address or
--resolve) as well as through the public proxy. - You know which layers are in the path — commonly an ALB or Nginx reverse proxy in front of an app that also has a CORS middleware enabled.
- You have permission to change configuration on at least one of the two offending layers.
Step-by-Step Fix
Step 1 — Confirm and Count the Duplicate
Count how many times the header appears in the edge response. Anything other than 1 is the bug:
curl -sI "https://api.example.com/v1/orders" \
-H "Origin: https://app.example.com" \
| grep -ci '^access-control-allow-origin'
# prints 2 → duplicate confirmed
Step 2 — Locate Both Emitting Layers
Curl the application directly and then through the proxy. If the header is present once at the origin and the proxy also adds one, you have found both sources:
# Direct to app (bypass proxy): expect one header
curl -sI --resolve api.example.com:8080:10.0.3.14 \
"http://api.example.com:8080/v1/orders" \
-H "Origin: https://app.example.com" | grep -i allow-origin
# Through the edge: two headers means the proxy adds a second
curl -sI "https://api.example.com/v1/orders" \
-H "Origin: https://app.example.com" | grep -i allow-origin
Step 3a — Fix in Nginx: Strip Upstream, Emit Once
Keep CORS at the proxy and remove the application’s copy with proxy_hide_header. This stops the upstream header from reaching the client, so only the proxy’s add_header survives:
location /v1/ {
proxy_pass http://backend_upstream;
# Drop the app's copies before adding our own
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
}
$cors_origin should come from an allowlist map, never a naked $http_origin reflection — see Configuring CORS in Nginx for Multiple Origins.
Step 3b — Or Disable CORS at the App
The cleaner option is often to strip CORS from the application and let a single layer own it. In Express, remove the cors() middleware so only the proxy emits the header:
// Before: app also sets the header → duplication
// app.use(cors({ origin: 'https://app.example.com' }));
// After: app emits no Access-Control-* headers;
// the proxy in front is the single source of truth.
Step 3c — Or Disable CORS at the ALB
If an AWS Application Load Balancer (or its listener rules) injects CORS while the app also does, disable it on exactly one. When the app already allowlist-matches origins correctly, remove the load-balancer CORS rule and let the app be the sole emitter. Never leave both enabled.
Step 4 — Re-verify a Single Value
curl -sI "https://api.example.com/v1/orders" \
-H "Origin: https://app.example.com" \
| grep -ci '^access-control-allow-origin'
# prints 1 → fixed
Verification
# One value only, and it matches the request origin exactly
curl -sI "https://api.example.com/v1/orders" \
-H "Origin: https://app.example.com" \
| grep -i '^access-control-allow-origin'
# Expect exactly: Access-Control-Allow-Origin: https://app.example.com
In Chrome DevTools → Network, select the request and read the Response Headers pane. Access-Control-Allow-Origin must appear once with a single origin value. Reload with the cache disabled to be sure you are not reading a stale edge-cached response.
Security Boundary Note
Do not resolve the duplicate by having a layer reflect $http_origin unconditionally just to guarantee “one value.” A single reflected header that echoes any origin is worse than a duplicate — it grants every browser page access. The surviving layer must allowlist-match the origin before reflecting it. See Wildcard Risks & Mitigation and, for credentialed requests, Credential Sharing & Security Boundaries — Access-Control-Allow-Origin must be an exact origin, never *, whenever cookies or Authorization are involved.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Disabling CORS on both layers to “start clean” | Header now missing entirely; every cross-origin request blocked | Disable on one layer only; keep exactly one emitter |
Using add_header at the proxy without proxy_hide_header |
Upstream copy still passes through, so the duplicate persists | Add proxy_hide_header Access-Control-Allow-Origin before add_header |
| Forgetting the credentials header when moving CORS to the proxy | Credentialed requests break because Access-Control-Allow-Credentials is now absent |
Also hide and re-emit Access-Control-Allow-Credentials: true |
| Assuming two identical values are harmless | Browser still rejects the comma-joined list | Ensure the response carries a single header line only |
FAQ
Why does the browser reject two identical Access-Control-Allow-Origin values?
The Fetch specification’s CORS check expects a single origin value or the wildcard. When two layers each add the header, the browser sees either two header lines or one comma-joined value, and neither is a valid single origin. The check fails even if both values are identical, because https://app.example.com, https://app.example.com is not a byte-for-byte match for the request’s origin.
Should I fix duplicate headers at the proxy or the application?
Fix it wherever you can most reliably allowlist-match the Origin, then remove CORS from the other layer entirely. If the proxy already terminates preflight and knows the allowed origins, disable CORS in the app and strip any residual upstream headers at the proxy. If the app owns CORS, disable it on the proxy or load balancer instead.
Does proxy_hide_header remove the client’s request header too?
No. proxy_hide_header in Nginx only prevents a response header received from the upstream from being passed on to the client. It does not touch request headers and does not affect headers Nginx itself adds with add_header. It is exactly the tool for stripping an upstream application’s duplicate Access-Control-Allow-Origin before the proxy injects its own.