How to Configure CORS in Nginx for Multiple Allowed Origins

When a browser sends a preflight OPTIONS request to an Nginx endpoint that serves several front-end applications, it expects the response to echo back the exact Origin it sent — not a wildcard, and not a hardcoded single domain. If Nginx returns the wrong value, or nothing at all, the browser blocks the actual request with a console error before any data is transmitted.

This page is part of Dynamic Origin Validation Patterns, which covers runtime origin evaluation on the server side.

Exact Error This Page Resolves

One of the following errors appears in the browser console:

No 'Access-Control-Allow-Origin' header is present on the requested resource.
The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.
The value of the 'Access-Control-Allow-Origin' header in the response must not
be the wildcard '*' when the request's credentials mode is 'include'.

Root Cause

The WHATWG Fetch Standard requires the server to return Access-Control-Allow-Origin set to the exact string the browser sent in the Origin request header. Nginx has no built-in mechanism to read that header and echo it back — you must wire this up explicitly. Static add_header Access-Control-Allow-Origin * fails for credentialed requests. Hardcoding a single domain fails for every other allowed domain. The map directive is the correct tool: it evaluates $http_origin before location processing and maps it to a variable your add_header directives can consume.

A secondary cause is missing or misplaced Vary: Origin, which allows CDN and reverse-proxy caches to serve one origin’s preflight response to a different requesting origin — the classic cache poisoning scenario.

Prerequisite State

Before applying the fix below:

Origin Validation Flow

The diagram below shows how Nginx evaluates an incoming request against the map block and routes it to the correct response path.

Nginx CORS origin validation flow A flowchart showing how Nginx evaluates the Origin header using a map directive and branches between OPTIONS preflight and regular requests. Incoming request map $http_origin $cors_origin → "" or $http_origin $cors_origin non-empty? No No CORS header set Yes Method = OPTIONS? Yes 204 + preflight hdrs No proxy_pass + CORS headers

Step-by-Step Fix

Step 1 — Define the map block in the http context

Place this outside any server block, in the http {} section (usually /etc/nginx/nginx.conf or an included file):

map $http_origin $cors_origin {
  default "";
  ~^https://(app|admin|portal)\.example\.com$   $http_origin;
  ~^https://[a-z0-9-]+\.trusted-partner\.io$    $http_origin;
}

The default "" returns an empty string for any origin not in the allowlist. An empty Access-Control-Allow-Origin header is ignored by browsers — it does not block the request, it simply grants no cross-origin access. The ~ prefix enables case-sensitive PCRE matching. Anchors (^ and $) prevent substring spoofing such as https://evil-app.example.com.attacker.net.

Step 2 — Add CORS headers to the location block

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

  location /api/ {
    # Reflect validated origin; Vary isolates CDN cache per requesting origin
    add_header Access-Control-Allow-Origin  $cors_origin always;
    add_header Access-Control-Allow-Credentials true       always;
    add_header Vary                          Origin         always;

    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Methods  "GET, POST, PUT, DELETE, OPTIONS" always;
      add_header Access-Control-Allow-Headers  "Authorization, Content-Type"     always;
      add_header Access-Control-Max-Age        600                               always;
      return 204;
    }

    proxy_pass http://backend;
  }
}

The always flag ensures headers are injected on error responses (4xx, 5xx) as well as 2xx — without it, a 401 from an authentication guard strips the CORS headers and the browser reports a network error rather than the actual auth failure.

Step 3 — Reload Nginx

nginx -t && systemctl reload nginx

Always run nginx -t first. A syntax error in the map block silently falls through to default "", stripping CORS headers from every request.

Verification

Run these two curl commands — one per allowed origin — and confirm each response echoes back the correct origin:

curl -si -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" \
  https://api.example.com/api/health \
  | grep -E "HTTP/|Access-Control|Vary"
curl -si -X OPTIONS \
  -H "Origin: https://admin.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" \
  https://api.example.com/api/health \
  | grep -E "HTTP/|Access-Control|Vary"

Each should return:

HTTP/2 204
access-control-allow-origin: https://app.example.com   (or admin, matching what you sent)
access-control-allow-credentials: true
vary: Origin
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Authorization, Content-Type
access-control-max-age: 600

Then confirm in DevTools:

Security Boundary Note

Do not pair Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *. The WHATWG Fetch Standard prohibits this combination and browsers enforce it without exception — the request is blocked before any response body is read. If you need unauthenticated public access from any origin, drop the Credentials header entirely rather than switching to a wildcard. For the full rationale on wildcard risks and when reflection is the only correct option, consult the dedicated page.

Additionally, never reflect $http_origin without the map validation gate. Without the allowlist, any site on the internet can make credentialed requests to your API and read the response — a direct CSRF and data-exfiltration vector.

Common Mistakes

Issue Root Cause Fix
Access-Control-Allow-Origin missing on error responses add_header without always keyword Add always to every add_header directive in the location block
Duplicate Access-Control-Allow-Origin header add_header in both server {} and location {} blocks Remove the directive from the server {} block; child location blocks that define their own add_header directives do not inherit parent ones
Wildcard * with Access-Control-Allow-Credentials: true WHATWG Fetch spec violation Replace * with $cors_origin from the map block
CDN serves first origin’s response to all subsequent origins Missing Vary: Origin Add add_header Vary Origin always; to handle the Vary: Origin header correctly

FAQ

Why does my Nginx CORS config work for one domain but fail for another?

Missing Vary: Origin causes proxy cache poisoning. The CDN or Nginx proxy cache stores the preflight response keyed to the first requesting origin, then serves it to every subsequent origin. https://admin.example.com receives a cached response with Access-Control-Allow-Origin: https://app.example.com and the browser blocks it. Add Vary: Origin and purge the cache. Also confirm the second domain is in the map block’s regex alternation group.

Can I use if blocks for CORS header assignment in Nginx?

Only for method routing — if ($request_method = OPTIONS) is safe. For header value assignment, always use map. The if directive in Nginx runs during the rewrite phase, not the header injection phase. Headers set inside if blocks for value selection are frequently ignored in certain location contexts, which leads to silent failures with no error in the Nginx error log.

How do I handle preflight caching with multiple origins?

Set Access-Control-Max-Age to 600 — the maximum that Chrome and Safari honour (Firefox accepts up to 86400). Ensure Vary: Origin is present so every CDN and browser cache stores a separate preflight response per requesting origin. Without Vary, a cache hit for origin A incorrectly serves as a cache hit for origin B.