> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The CORS Header Is Present Twice and That Is Why It Fails
- URL: https://debugly.dev/cors-duplicate-headers-proxy-and-app/
- Published: 2026-08-25T13:00:00.000Z
- Updated: 2026-10-17T06:04:44.000Z
- Description: You added the header at the proxy, your framework was already sending it, and the browser now rejects both. Duplicates are invalid, not additive.
- Author: Rohit Bhadani
- Tags: MicroVMs, Error Autopsy, Networking, Web Development

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.

```bash
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:

```js
app.use(cors({ origin: 'https://app.example.com', credentials: true }))

```

And the infrastructure adds it too, either in nginx:

```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](https://krova.cloud/?ref=debugly.dev) 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.

```js
// 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.

```bash
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.

```bash
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](https://debugly.dev/every-bug-is-a-wrong-assumption/), the message was accurate and my reading of it was not.

---

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)