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.

Following the header field from the two places it is written to the point the browser compares it explains why identical values still fail:

How two identical headers become one invalid value An application middleware and a reverse proxy each write Access-Control-Allow-Origin. The field name repeats on the wire, the browser's header parser joins the repeated fields with a comma, and the joined string fails the byte comparison against the request Origin. Application CORS middleware access-control-allow-origin: https://app.example.com Reverse proxy add_header access-control-allow-origin: https://app.example.com On the wire the field name simply repeats — HTTP permits that for any header access-control-allow-origin: https://app.example.com access-control-allow-origin: https://app.example.com The browser's header parser folds repeated fields into one comma-joined value https://app.example.com, https://app.example.com That joined string is not a single origin, so the byte comparison against Origin: https://app.example.com fails and the response is blocked

Prerequisite State

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

Read the two counts as a pair rather than one at a time — the combination, not either number alone, names the layer to change:

Reading the two header counts as a pair A four row table. The first two columns hold the number of Access-Control-Allow-Origin headers seen when curling the application directly and through the edge. The third column states what that pair means, from the edge appending a second copy to the application emitting twice on its own. curl direct to the app curl through the edge what that pair of counts means 1 2 the edge appends a second copy — hide the upstream header there, or take CORS out of the application 0 1 the edge is the sole emitter — this is the healthy shape you are working towards 2 2 the application emits twice on its own — a CORS middleware mounted on both the app and its router 1 1 the edge replaces rather than appends — safe only while it hides the upstream header before adding Two counts localise the second emitter before you change a single line of configuration

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.

Whichever layer you pick, the fix is easier to verify as a running tally than as a config diff — count the copies on the response as it climbs each layer:

Header count at each layer after remediation Four stages down the left: the application with its CORS middleware removed, the proxy hiding any upstream copy, the proxy adding one allowlist-matched header, and the browser. Beside each stage is the number of Access-Control-Allow-Origin copies the response carries at that point: zero, zero, one, one. Track the copies as the response climbs the stack — the target is exactly one, and only one layer may add it Application cors() middleware removed entirely 0 copies on the response the app emits no Access-Control headers Proxy — proxy_hide_header Access-Control-Allow-Origin 0 copies on the response any residual upstream copy is dropped Proxy — add_header … always with an allowlist-matched origin 1 copy on the response this layer is now the single owner Browser runs the CORS check on what arrived 1 copy on the response a single origin value, so the check passes Remediation here is subtractive: nothing new is added until every other layer has been silenced

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 BoundariesAccess-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.