Site icon GetPageSpeed

NGINX CORS Configuration: Why the Recipe You Copied Is Broken

NGINX CORS Configuration: The Complete Guide

đź“… Updated: September 14, 2026 (Originally published: January 24, 2026)

There is one NGINX CORS configuration that has been copied into more production servers than any other. You know it: a map $http_origin block at the top, a handful of add_header Access-Control-Allow-* lines in the location, an if ($request_method = OPTIONS) { return 204; } for preflight. It is on Stack Overflow, it is in a dozen blog posts, and it is almost certainly in your config right now.

It is also broken in four specific ways. Not “suboptimal” – broken, in ways that produce real outages, real cache-poisoning incidents, and the specific class of bug where your API works perfectly in every test you run and fails for one user behind one CDN.

This guide walks through all four with reproductions, shows the hardened hand-rolled config that fixes what can be fixed, and shows where the add_header approach runs out of road entirely.

Failure 1: Vary: Origin is missing, and your CDN is poisoning itself

This is the one that does the most damage and gets the least attention, so it goes first.

When you reflect the request’s Origin back in Access-Control-Allow-Origin, the response body may be identical for every caller, but the response is no longer the same for everyone. It now depends on a request header. Every shared cache in front of you – your CDN, a corporate proxy, a Varnish tier – needs to be told that, and the only way to tell it is Vary: Origin.

Leave it out and the sequence is brutally simple:

  1. A user on https://app.example.com hits /api/config. NGINX reflects their origin: Access-Control-Allow-Origin: https://app.example.com.
  2. Your CDN caches that response. No Vary, so it caches it under a key that ignores Origin.
  3. A user on https://admin.example.com – an origin you also allow – hits the same URL.
  4. The CDN serves the cached copy, complete with Access-Control-Allow-Origin: https://app.example.com.
  5. The browser compares that against the actual origin, sees a mismatch, and blocks the response.

The admin app breaks. Nothing in your logs shows an error – NGINX returned 200, the CDN returned 200, the bytes were correct. Only the browser knows, and all it tells you is a generic CORS message.

The same mechanism runs in reverse and is worse. If the first request to populate the cache arrives with no Origin header at all – a health check, a curl, a server-side fetch, a monitoring probe – then the cached response has no Access-Control-Allow-Origin header. Every cross-origin browser request afterwards gets that header-less copy and fails, until the entry expires. This is why the classic symptom is “CORS broke for an hour and then fixed itself”.

Reproducing it

Point any caching layer at an origin using the standard recipe, then:

# Populate the cache as one origin
curl -s -D- -o /dev/null https://api.example.com/config \
  -H 'Origin: https://app.example.com' | grep -i 'access-control-allow-origin\|vary'

# Now ask as a different, also-allowed origin
curl -s -D- -o /dev/null https://api.example.com/config \
  -H 'Origin: https://admin.example.com' | grep -i 'access-control-allow-origin\|x-cache'

If the second response comes back with Access-Control-Allow-Origin: https://app.example.com and a cache HIT, you have reproduced it.

The fix, and the part nearly everyone gets wrong

The obvious fix is to add the header:

add_header Vary Origin always;

That is necessary, and it is not sufficient, because of a subtlety that trips up almost every guide that bothers to mention Vary at all.

add_header runs in the header filter chain, which executes after NGINX has already decided what to store in proxy_cache. NGINX keys its own cache variance off the Vary header it received from the upstream, not off anything you add locally. So add_header Vary Origin correctly instructs your CDN and the browser, but it does nothing for NGINX’s own proxy_cache.

If NGINX itself is caching, you need the origin in the cache key as well:

proxy_cache_key "$scheme$request_method$host$request_uri$http_origin";

Or have the upstream application emit Vary: Origin itself, which NGINX will then honour for its own cache. Doing neither leaves you poisoning your own cache while your CDN behaves correctly – a genuinely confusing failure to debug, because purging the CDN appears to fix it for a while.

Note also that Vary: Origin is only correct when the response actually depends on the origin. A static Access-Control-Allow-Origin: * is identical for everybody, so adding Vary: Origin there just fragments your cache for no benefit.

Failure 2: add_header is inherited-or-replaced, and a single line miles away silently deletes your CORS

This is the failure mode that bites during a refactor, months after the CORS config was last touched.

add_header directives are inherited from the enclosing level only if the current level defines no add_header directives of its own. Not merged – replaced, wholesale. The moment any nested location adds any single header for any unrelated reason, every inherited add_header above it disappears.

server {
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Vary Origin always;

    location /api/ {
        proxy_pass http://backend;
        # CORS headers inherited here. Fine.
    }

    location /api/download/ {
        add_header X-Content-Type-Options nosniff always;   # <-- one unrelated line
        proxy_pass http://backend;
        # ALL THREE CORS headers are now gone. No warning, no log entry.
    }
}

Somebody adding a security header, a Cache-Control, or an X-Request-Id to one endpoint has just broken CORS for that endpoint. nginx -t passes. Nothing in the error log. The endpoint keeps working for every non-browser client, so your integration tests stay green.

Reproducing it

curl -sI https://api.example.com/api/things -H 'Origin: https://app.example.com' \
  | grep -ci access-control-allow-origin      # 1

curl -sI https://api.example.com/api/download/report.csv -H 'Origin: https://app.example.com' \
  | grep -ci access-control-allow-origin      # 0

The fix

There are only two real options with add_header. Either repeat every CORS header at every level that defines any header at all - which is precisely the kind of duplication that decays the moment someone adds a location - or move to a directive with sane inheritance, such as more_set_headers from the headers-more module, which is discussed at length in our write-up on the pitfalls of add_header in NGINX.

Failure 3: no always, so your 401 becomes an opaque CORS error

By default add_header only adds headers to a whitelist of status codes: 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308. Every other status - every 4xx, every 5xx - gets nothing.

This is visible directly in ngx_http_headers_filter_module.c:

switch (r->headers_out.status) {
case NGX_HTTP_OK:
case NGX_HTTP_CREATED:
case NGX_HTTP_NO_CONTENT:
/* ... other 2xx and 3xx codes ... */
    safe_status = 1;
    break;
default:
    safe_status = 0;
    break;
}

if (!safe_status && !h[i].always) {
    continue;  /* header is skipped */
}

The consequence is not "a missing header". The consequence is that your error handling stops working. Your frontend calls the API, the token has expired, the API correctly returns 401 {"error": "token expired"} - and the browser blocks the response because it has no CORS headers. Your JavaScript never sees the 401. It sees a network-level CORS failure with no status code and no body.

So the user gets "something went wrong" instead of "your session expired, log in again", and your frontend cannot implement token refresh, because it cannot distinguish a 401 from a 502 from the server being on fire. Every one of them arrives as the same opaque failure.

The same applies to your 502 Bad Gateway responses, which is why CORS errors mysteriously spike during a backend deploy.

The fix

Append always to every CORS add_header, without exception:

add_header Access-Control-Allow-Origin $cors_origin always;

Then test it explicitly against an endpoint that errors, because this is the one thing nobody tests:

curl -sI https://api.example.com/api/definitely-not-a-real-path \
  -H 'Origin: https://app.example.com' | grep -i access-control

Failure 4: the preflight short-circuit answers requests it should never touch

The standard preflight handler looks like this:

if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin $cors_origin always;
    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 86400 always;
    return 204;
}

First, a correction to a widespread myth: this is not an instance of "if is evil". if combined with return is one of the two explicitly safe patterns. The problems with if come from combining it with proxy_pass, try_files or fastcgi_pass. That is not what is wrong here.

What is wrong here is narrower and more practical.

It short-circuits every OPTIONS request, not just preflights. A genuine CORS preflight is an OPTIONS request carrying both an Origin header and an Access-Control-Request-Method header. This block does not check for either. So it also intercepts and 204s:

It answers preflights from origins you never allowed. With $cors_origin empty, NGINX returns a bare 204. The browser does then reject it - correctly, because the allow header is absent - but you are answering unauthenticated 204s to the entire internet on every path.

Nothing above the if is inherited into it. An if block is a nested configuration context, so it inherits add_header under exactly the rule from Failure 2: because the if block defines its own add_header directives, everything from the enclosing level is dropped. That is why the preflight block has to repeat every header, and why preflight and actual responses drift apart over time as one gets updated and the other does not.

It bypasses your authentication. if ... return runs in the rewrite phase, ahead of the access phase, so the 204 is returned before auth_basic, auth_request or deny ever run. For genuine preflights that is exactly what you want - browsers never send credentials on a preflight, so authenticating one breaks CORS entirely. For the WebDAV and application OPTIONS requests it also swallows, it is an authentication bypass you did not intend.

The hardened hand-rolled configuration

Applying every fix above to the recipe gives you this. If you are staying with add_header, use this version rather than the one you copied:

# http level
map $http_origin $cors_origin {
    default                                  "";
    "~^https://(app|admin)\.example\.com$"   $http_origin;
}

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

    location /api/ {
        # Every header repeated inside the if block: nothing is inherited into it.
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin      $cors_origin always;
            add_header Access-Control-Allow-Credentials true         always;
            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           86400        always;
            add_header Vary                             Origin       always;
            return 204;
        }

        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials true         always;
        add_header Access-Control-Expose-Headers    "X-Total-Count" always;
        add_header Vary                             Origin       always;

        # Required if NGINX itself caches: add_header Vary does NOT affect proxy_cache.
        proxy_cache_key "$scheme$request_method$host$request_uri$http_origin";

        proxy_pass http://backend;
    }
}

A detail worth knowing: when $cors_origin is empty, NGINX omits the header entirely rather than sending an empty one. ngx_http_add_header() only appends when the evaluated value has non-zero length. Several popular guides claim an empty value is sent and that browsers treat it as a denial. The outcome is the same - the browser denies - but the mechanism is different, and it matters when you are reading response headers to debug.

Anchor your regex properly, too. ~example\.com with no anchors happily matches https://malicious-example.com.attacker.net. The map directive guide covers the matching rules in detail.

What this config still cannot do

That configuration is about as good as add_header gets, and it still has hard limits:

Doing it as a module instead

These are structural limits of a header-rewriting directive being used to implement a protocol. Handling CORS properly means a header filter plus a phase handler, which is what nginx-module-cors is:

location /api/ {
    cors                on;
    cors_origin         https://app.example.com https://*.staging.example.com;
    cors_methods        GET HEAD POST PUT DELETE;
    cors_headers        Authorization Content-Type;
    cors_expose_headers X-Total-Count;
    cors_credentials    on;
    cors_max_age        86400;

    proxy_pass http://backend;
}

Each directive inherits independently, so a nested location that overrides one of them keeps the rest - the Failure 2 class stops existing. Headers are emitted on every status including errors, so Failure 3 stops existing. There is one place to configure, not two.

Vary: Origin is emitted automatically whenever the policy is origin-dependent, and this is worth being precise about, because it is where implementations differ. It is emitted when the origin matched, when it did not match, and when the request carried no Origin header at all. Those last two are exactly the cases that poison a cache, and they are the cases most commonly skipped: a response generated for a rejected origin, or for a request with no origin, must never be replayed to a request that would have produced different headers. It is deliberately not emitted for a static cors_origin *, which genuinely does not vary.

The same caveat as before still applies for NGINX's own proxy_cache: any module that sets Vary in the header filter chain informs downstream caches, not NGINX's own cache storage. Use proxy_cache_key with $http_origin, or have the upstream send Vary, if NGINX is doing the caching.

Preflights are answered only when the request is genuinely one - OPTIONS carrying both Origin and Access-Control-Request-Method, from an origin that matches. Everything else falls through untouched, so WebDAV and application OPTIONS keep working. Because the handler runs in the preaccess phase, preflights are answered ahead of auth_basic and auth_request, while actual requests to the same location are still authenticated normally.

Three mistakes it refuses to let you make

Credentials paired with a wildcard is rejected at configuration time. The CORS specification forbids it and every browser enforces it, so a config that does both is a config whose CORS never works anywhere. Rather than let that reach production, NGINX refuses to start:

cors_credentials on;
cors_origin      *;   # nginx: [emerg] ... cannot be combined with "cors_origin *"

Use an explicit list, or cors_origin any to reflect the request origin, which is credential-safe and emits Vary.

Sloppy wildcards are rejected at startup. In cors_origin, the * must come immediately after :// and must be followed by a dot. https://*example.com - which would match https://evilexample.com - is a configuration error, not a silently-accepted rule. https://*.example.com matches any number of leading labels but not the bare apex, and does not ignore a port.

Origin: null is not reflected by accident. Sandboxed iframes, data: documents and file:// pages all send Origin: null. Reflecting that back with credentials enabled hands every sandboxed frame on the internet an authenticated read of the response. It is matched only by an explicit null entry in the origin list; neither any nor a regular expression will match it when credentials are on.

The full directive reference covers the remaining behaviour, including how an existing upstream Vary is extended rather than replaced.

Testing whatever you end up with

Whichever approach you take, these four checks catch the four failures. Run them against production, not a staging box with no CDN in front of it.

# 1. Preflight is answered with the right headers
curl -si -X OPTIONS https://api.example.com/api/things \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: Authorization' | head -20

# 2. Vary: Origin is present on the actual response
curl -sI https://api.example.com/api/things -H 'Origin: https://app.example.com' \
  | grep -i '^vary'

# 3. Error responses still carry CORS headers
curl -sI https://api.example.com/api/no-such-path -H 'Origin: https://app.example.com' \
  | grep -i access-control-allow-origin

# 4. A second allowed origin is not served the first one's cached headers
curl -sI https://api.example.com/api/things -H 'Origin: https://admin.example.com' \
  | grep -i 'access-control-allow-origin\|x-cache'

Then walk every location that adds any header of its own and re-run check 3 against it. That is where the inheritance failure hides.

If you would rather have the whole config checked for this and the rest of the usual suspects, our free NGINX config checker validates a pasted config without installing anything. And if these rules arrived with you from Apache, the Apache .htaccess to NGINX converter handles the translation rather than leaving you to hand-port Header set Access-Control-Allow-Origin directives.

Quick reference

Header Purpose Gotcha
Access-Control-Allow-Origin Which origin may read the response Cannot be * with credentials; two of them is a hard browser failure
Vary Tells shared caches the response depends on Origin add_header does not affect NGINX's own proxy_cache
Access-Control-Allow-Credentials Permits cookies and HTTP auth Requires an exact origin, never a wildcard
Access-Control-Allow-Methods Methods permitted, sent on preflight Preflight only; pointless on actual responses
Access-Control-Allow-Headers Request headers permitted, sent on preflight Must list every custom header the client sends
Access-Control-Expose-Headers Response headers JavaScript may read Actual responses only; default set is six safelisted headers
Access-Control-Max-Age How long the preflight result may be cached Chrome caps at 7200s regardless of what you send

Summary

  1. Vary: Origin on every origin-dependent response, and $http_origin in proxy_cache_key if NGINX caches. This is the failure that poisons caches and produces bugs that appear to fix themselves.
  2. Assume add_header inheritance will break. Any nested location adding any header drops every inherited one, silently.
  3. always on every CORS header, or your 401s and 502s reach the browser as opaque CORS failures and your frontend cannot handle them.
  4. Gate the preflight short-circuit on Origin plus Access-Control-Request-Method, or it will eat WebDAV and application OPTIONS too.

Fix these in whatever you are running today. If you would rather not maintain the fixes by hand across every location block, nginx-module-cors implements the protocol properly and refuses the configurations that cannot work.

Further reading

CORS rules are easy to ship and easy to widen by accident. A wildcard that crept into the headers after a refactor is exactly the regression gixy catches. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version