Troubleshooting

Why does the browser say Access-Control-Allow-Origin contains multiple values behind a reverse proxy?

Two layers both add the CORS header, so the browser sees two values and blocks the response. How to prove it with curl and pick one owner in nginx, Traefik or Caddy.

· 14 min read · How we verify this

Key points

  • The error means two layers both wrote Access-Control-Allow-Origin, usually the application and the proxy, or two proxies. The browser joins them into one value such as https://app.example.com, https://app.example.com, which never equals the request's origin, so even two identical copies fail.
  • The fix is one owner. When the proxy owns CORS, use proxy_hide_header Access-Control-Allow-Origin; next to its add_header. When the application owns it, delete the proxy's add_header. always does not remove duplicates: it makes add_header apply to 4xx and 5xx responses too.
  • Chrome reports this same error for a single header that lists several origins, because it flags any value containing a space or a comma. A list of origins is not valid syntax: echo the one allow-listed Origin and send Vary: Origin.
  • In Caddy, a plain header Access-Control-Allow-Origin ... before reverse_proxy still produced two copies in testing. Only the deferred > form or a delete leaves one. Traefik's accessControlAllowOriginList overwrites the backend's value only for an origin on its own list.

The browser says Access-Control-Allow-Origin contains multiple values because the response really does carry it twice: the application sets it, and the reverse proxy in front adds its own with add_header (or a second proxy does the same). The browser reads both copies as a single value, https://app.example.com, https://app.example.com, which is not the request's origin, so it blocks the response even when the two copies are identical. The fix is to make exactly one layer own CORS. If the proxy owns it, add proxy_hide_header Access-Control-Allow-Origin; beside its add_header so the application's copy is dropped. If the application owns it, delete the proxy's add_header. Adding always to the add_header does not remove the duplicate.

Chrome words it as The 'Access-Control-Allow-Origin' header contains multiple values 'https://app.example.com, https://app.example.com', but only one is allowed. Firefox reports Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed.

Prove it in one command#

Send the request the browser sends, with its Origin, and dump the response headers. curl through a proxy covers the flags; the ones that matter here are -sS -o /dev/null -D -, which print the headers and throw the body away:

bash
curl -sS -o /dev/null -D - -H 'Origin: https://app.example.com' https://api.example.com/v1/items \
  | grep -i '^access-control-allow-origin'

Then repeat it past the proxy, straight at the backend, from the proxy host:

bash
curl -sS -o /dev/null -D - -H 'Origin: https://app.example.com' http://127.0.0.1:8000/v1/items \
  | grep -i '^access-control-allow-origin'

Read the two results together:

Through the proxyDirect to the backendWhat is happeningFix
Two linesOne lineApp and proxy both set itPick one owner (below)
Two linesNo lineTwo proxy layers both set it, or one proxy sets it at two levelsRemove it from all but one layer
One line with a comma or space in itSame lineThe app sends a list of originsEcho one origin from an allow list
One line with a comma or space in itOne clean lineA proxy is appending to the app's valueFind the rule that appends (in Caddy, a + prefix)
One clean lineOne clean lineNot this fault: check the preflightRun the OPTIONS test further down

Test a success path and a failing path (a 404 or a 500): an nginx add_header without always appears on one and not the other. If the chain has more hops than you think, the proxy debugging playbook has the method for finding each one and testing it in isolation.

Why two identical copies still fail#

The CORS check begins by getting Access-Control-Allow-Origin from the response's header list, and the Fetch "get" algorithm returns "the values of all headers in list whose name is a byte-case-insensitive match for name, separated from each other by 0x2C 0x20, in order". Two field lines therefore become one string with a comma and a space in it. The check then requires that string to equal the serialised request origin byte for byte, and https://app.example.com, https://app.example.com does not equal https://app.example.com. The grammar agrees: the header's ABNF is origin-or-null / wildcard, a single value with no list form. RFC 9110 section 5.3 says a sender "MUST NOT generate multiple field lines with the same name" unless the field's definition allows a comma-separated list, and this one does not.

Chromium (services/network/public/cpp/cors/cors.cc) makes the same comparison, and when the value does not match and contains a space or a comma it reports kMultipleAllowOriginValues, the "contains multiple values" message. That has a consequence the forum threads miss:

Vary: Origin appearing twice is not a problem. Vary is defined as a list (#( "*" / field-name )), so two field lines combine into Origin, Origin, which is valid. In the tests below the duplicate Vary lines were left alone deliberately.

The fix in nginx: choose the layer that owns CORS#

Both options below were tested on nginx 1.30.5 (official Alpine image) in front of a small Python backend that set Access-Control-Allow-Origin and Vary: Origin itself.

Option 1: the application owns CORS#

Delete every add_header Access-Control-Allow-* from the proxy configuration, including any in an included snippet, and let the application's headers pass through. nginx passes upstream headers unchanged unless told otherwise: by default it hides only Date, Server, X-Pad and X-Accel-... from the proxied response. Choose this when rules differ per route or depend on credentials.

Option 2: the proxy owns CORS#

Hide the application's header and add your own. proxy_hide_header "sets additional fields that will not be passed" from the proxied response:

nginx
map $http_origin $cors_origin {
    default                     "";
    "https://app.example.com"   $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Headers;

    location / {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin  $cors_origin always;
            add_header Access-Control-Allow-Methods "GET, PUT" always;
            add_header Access-Control-Allow-Headers "content-type" always;
            add_header Access-Control-Max-Age       600 always;
            add_header Vary Origin always;
            return 204;
        }
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary Origin always;
        proxy_pass http://app_backend;
    }
}

Observed behaviour with that configuration:

RequestResponse headers
GET / with Origin: https://admin.example.comOne Access-Control-Allow-Origin: https://admin.example.com, plus Vary: Origin
GET / with Origin: https://evil.example.netNo Access-Control-Allow-Origin at all: the map gave an empty string and nginx sent no header for it
GET /err (backend 500) with an allowed originOne Access-Control-Allow-Origin, because of always
OPTIONS / with Access-Control-Request-Method: PUT204, the four CORS headers, and the backend never saw the request

The map is what replaces a list of origins. The Fetch Standard describes Access-Control-Allow-Origin as "returning the literal value of the Origin request header (which can be null) or *", and MDN's page on this error says the same in practice: "You cannot send back a list of origins, because browsers only accept a value that is either a single origin or null". Echo the request's origin only when it is on your list, never unconditionally, because an unconditional echo is the same as * but also works with credentials.

The Vary: Origin line matters as soon as the value depends on the request. The Fetch Standard says that when CORS is "more complicated than setting Access-Control-Allow-Origin to or a static origin, Vary is to be used", and explains the failure without it: a response cached from a non-CORS request, with no Access-Control-Allow-Origin, gets reused for a later CORS request. Shared caches in front of the API have the same problem; caching in reverse proxies covers how Vary shapes the cache key. For a fixed value (` or one static origin) the spec says the opposite: send the header on every response and do not use Vary`.

Trap 1: "always" does not deduplicate#

A common answer to this error is to add always. It does not help. The nginx documentation says add_header adds a field "provided that the response code equals 200, 201 (1.3.10), 204, 206, 301, 302, 303, 304, 307 (1.1.16, 1.0.13), or 308 (1.13.0)", and that with always (1.7.5) "the header field will be added regardless of the response code". Nothing in either sentence removes an existing header.

With the backend setting the header and the proxy adding the same value:

Backend statusadd_header ...add_header ... always
200Two copiesTwo copies
404One copy (the app's)Two copies
500One copy (the app's)Two copies

So always spreads the duplicate to the error responses that were working. Without it the bug is confusing in its own way: the CORS error appears only when the API succeeds.

always belongs in the proxy-owns-CORS configuration for a different reason: without it, an upstream error or nginx's own 502 or 504 goes out with no CORS header, and the browser reports No 'Access-Control-Allow-Origin' header is present on the requested resource instead of the status. Reading 502, 503 and 504 is much harder when every one of them looks like a CORS error.

Trap 2: an add_header in a location discards the server's#

add_header is inherited "from the previous configuration level if and only if there are no add_header directives defined on the current level". nginx as a reverse proxy warns about this replace-not-merge rule for proxy_set_header; for CORS it produces the opposite symptom, a missing header.

nginx
server {
    proxy_hide_header Access-Control-Allow-Origin;
    add_header Access-Control-Allow-Origin https://app.example.com always;

    location / { proxy_pass http://app_backend; }

    location /api/ {
        add_header Cache-Control no-store;      # this one line drops the CORS header
        proxy_pass http://app_backend;
    }
}

GET / returned one Access-Control-Allow-Origin. GET /api/ returned none: the location's own add_header cancelled the inherited one, while the inherited proxy_hide_header still removed the application's copy. The error turns from "multiple values" into "No 'Access-Control-Allow-Origin' header is present".

The same rule applies to proxy_hide_header, and it reverses the fix. With proxy_hide_header Access-Control-Allow-Origin; at server level and proxy_hide_header X-Internal-Debug; inside a location, that location returned two copies again: its own proxy_hide_header replaced the inherited list. The if block in Option 2 is another level, which is why it repeats every header.

Keep every add_header and proxy_hide_header at one level, or repeat the full set from an included snippet wherever a level needs one more. On nginx 1.29.3 and later, add_header_inherit merge; "enables appending values from the previous level to the values defined at the current level". In a test on 1.30.5, a location with add_header_inherit merge; and its own Cache-Control also returned the server-level Strict-Transport-Security. It covers add_header only, not proxy_hide_header. Older builds refuse it: nginx 1.27.5 failed nginx -t with unknown directive "add_header_inherit".

Trap 3: the preflight, answered by the proxy or passed through#

A non-simple request (a PUT, a JSON Content-Type, an Authorization header) is preceded by a preflight: an OPTIONS request with Origin and Access-Control-Request-Method. The Fetch Standard runs the same CORS check on the preflight response and also requires "an ok status, e.g., 200 or 204". So the duplicate can break the preflight before the real request is sent, and Chrome prefixes the message with Response to preflight request doesn't pass access control check:.

There are two working designs and two broken ones:

Who answers OPTIONSHeaders on the preflight responseResult
The application, with the proxy adding nothingThe app's setWorks
The proxy, with a full set of CORS headers (Option 2 above)The proxy's set, return 204Works, and the backend never sees preflights
The application, while the proxy also adds Access-Control-Allow-OriginTwo copies: 204 is in add_header's status list, so nginx adds its copy even without alwaysFails: multiple values
The proxy, with a bare if ($request_method = OPTIONS) { return 204; }NoneFails: no Access-Control-Allow-Origin

A snippet that short-circuits OPTIONS must send every header the preflight needs inside the if block. A related failure: the Fetch Standard says "a CORS-preflight request never includes credentials", so an authentication layer that demands a cookie or token on every request answers the preflight with 401 or a redirect, and the preflight fails on status. Forward auth in nginx, Traefik and Caddy covers that layer; it has to let OPTIONS through or answer it itself.

Traefik: the headers middleware#

Traefik's headers middleware has dedicated CORS options, tested here on Traefik 3.7.14:

yaml
http:
  middlewares:
    api-cors:
      headers:
        accessControlAllowOriginList:
          - https://app.example.com
          - https://admin.example.com
        accessControlAllowMethods: ["GET", "PUT"]
        accessControlAllowHeaders: ["content-type"]
        accessControlMaxAge: 600
        addVaryHeader: true

Two documented behaviours make this the simplest of the three. On the origin list, the reference says: "If this value is set by a backend service, it will be overwritten by Traefik". On preflights: "If CORS headers are set, then the middleware does not pass preflight requests to any service, instead the response will be generated and sent back to the client directly." Both held in testing: one Access-Control-Allow-Origin came back although the backend sent its own (even a two-origin list), and an OPTIONS with Origin and Access-Control-Request-Method got a 200 from Traefik without reaching the backend. A plain OPTIONS without those headers was passed through.

Without the CORS options, customResponseHeaders with Access-Control-Allow-Origin: "" strips the backend's header (the reference marks it "# Removes"), and a fixed value there replaces it, the same for every origin. Middleware attachment is covered in Traefik routers, services and middlewares.

Caddy: the header directive, and why "set" is not enough#

Caddy's documentation says that with no prefix "the field is set (overwritten)", so this looks safe:

caddyfile
api.example.com {
	header Access-Control-Allow-Origin https://app.example.com
	reverse_proxy app:8000
}

On Caddy 2.11.7 it returned two copies, on the 200 and on the 500. The header is set immediately, before reverse_proxy has copied the backend's response headers, and the backend's copy is then added alongside it. The documentation says as much for a different header: "enabling defer is necessary to ensure the header is set after the proxy writes its headers". Results for each form:

Caddyfile line before reverse_proxyCopies in the response
header Access-Control-Allow-Origin https://app.example.com2
header +Access-Control-Allow-Origin https://app.example.com2
header >Access-Control-Allow-Origin https://app.example.com1 (deferred set replaces the backend's)
header -Access-Control-Allow-Origin0 (backend's removed)
reverse_proxy app:8000 { header_down -Access-Control-Allow-Origin }0 (backend's removed)

Deletes and > are deferred automatically; the plain form is not. For a proxy-owned allow list, remove the backend's copy, then echo the origin only when it matches. Caddy's matcher documentation says that to match several values of one field you "specify one header matcher per value within the same matcher set; those values are OR'ed":

caddyfile
api.example.com {
	@cors {
		header Origin https://app.example.com
		header Origin https://admin.example.com
	}
	@preflight {
		method OPTIONS
		header Origin https://app.example.com
		header Origin https://admin.example.com
		header Access-Control-Request-Method *
	}
	header -Access-Control-Allow-Origin
	header +Vary Origin
	header @cors >Access-Control-Allow-Origin {http.request.header.Origin}
	handle @preflight {
		header {
			>Access-Control-Allow-Methods "GET, PUT"
			>Access-Control-Allow-Headers content-type
			>Access-Control-Max-Age 600
		}
		respond 204
	}
	handle {
		reverse_proxy app:8000
	}
}

In testing, an allowed origin got one echoed Access-Control-Allow-Origin, an unlisted origin got none, an allowed preflight got a 204 from Caddy without reaching the backend, and every response carried Vary: Origin. The wider Caddyfile structure is in Caddy as a reverse proxy.

Two proxies, each adding it#

The same fault appears with no application involvement: an edge nginx adds Access-Control-Allow-Origin, and an ingress or Traefik behind it does too. In a test with nginx in front of the Traefik middleware above, the response carried two copies, Traefik's and nginx's. Traefik's "overwritten" only applies to what its backend sent; it cannot see what a proxy in front of it adds afterwards. One layer owns CORS, normally the one closest to the application, and every outer layer neither adds the header nor strips it.

Failure modes#

contains multiple values 'X, X' on 200 responses, while 404s and 500s show normally. App and nginx both set it, nginx without always. Remove one owner; adding always makes every status fail.

contains multiple values '*, https://app.example.com'. One layer sends a wildcard and another echoes the origin. Remove the wildcard layer. A wildcard cannot be used with credentials in any case: "If credentials mode is "include", then Access-Control-Allow-Origin cannot be *".

Fixed at /, duplicated again under /api/. A location-level proxy_hide_header replaced the server-level one. Move all of them to one level.

Only PUT or JSON POST fails, with Response to preflight request doesn't pass access control check. Repeat the curl test with -X OPTIONS -H 'Access-Control-Request-Method: PUT' and the Origin header, then use the preflight table above.

Works until someone opens the API URL directly in a tab. The value is dynamic and Vary: Origin is missing, so the browser reused a cached non-CORS response.

Where this answer stops applying#

The nginx results are from 1.30.5, the Traefik results from 3.7.14 and the Caddy results from 2.11.7, all in official container images, with curl as the client. Browser behaviour is taken from the Fetch Standard and from current Chromium source, not from a browser run. The diagnosis covers Access-Control-Allow-Origin only. Duplicated Access-Control-Allow-Methods or Access-Control-Allow-Headers are list-valued and combine without this error, although a duplicated Access-Control-Allow-Credentials (true, true) fails the credentials check for the same reason. API gateways with their own CORS plugins are one more layer: test them with curl like any other hop.

Frequently asked questions#

Why does Access-Control-Allow-Origin contain multiple values?#

Because two layers set it: usually the application and the reverse proxy, sometimes two proxies. The browser joins both copies with a comma and a space, so the value no longer equals the request's origin.

Is it fine if both Access-Control-Allow-Origin headers have the same value?#

No. The Fetch Standard combines both copies into one string such as https://app.example.com, https://app.example.com before comparing it with the origin, so identical duplicates fail exactly like different ones.

Does add_header always fix duplicate CORS headers in nginx?#

No. always only makes nginx add the header to every response code, including 4xx and 5xx. It never removes the application's copy, so it spreads the duplicate to error responses. Use proxy_hide_header Access-Control-Allow-Origin; to drop the application's copy.

How do I allow multiple origins in Access-Control-Allow-Origin?#

Send one origin per response. Compare the request's Origin with an allow list (an nginx map, a Caddy header matcher or Traefik's accessControlAllowOriginList), echo it only when it matches, and add Vary: Origin.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. MDN: Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed
  2. WHATWG Fetch Standard: CORS protocol, CORS check, header list get, CORS protocol and HTTP caches
  3. RFC 9110 HTTP Semantics, section 5.3 Field Order
  4. nginx ngx_http_headers_module: add_header, always, add_header_inherit
  5. nginx ngx_http_proxy_module: proxy_hide_header
  6. Traefik headers middleware reference (CORS headers, accessControlAllowOriginList)
  7. Caddy header directive
  8. Caddy reverse_proxy directive (header_down)
  9. Caddy request matchers (header matcher)
  10. Chromium source: services/network/public/cpp/cors/cors.cc and blink cors_error_string.cc

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in troubleshooting proxies#