CORS in Nginx and Apache

Here's a scenario that plays out roughly once a week in every organization that has both backend developers and a DevOps team: the backend developer adds proper CORS headers to their application. Everything works in local development. They deploy behind Nginx or Apache, and CORS breaks. Or worse — it appears to work, but the response now contains duplicate CORS headers, which some browsers reject.

Configuring CORS at the reverse proxy layer is tricky not because the concepts are hard, but because the configuration syntax is hostile and the interaction between your proxy and your upstream application creates subtle failure modes.

Let's do this properly.


Nginx

Nginx is the most common reverse proxy you'll encounter in front of API servers. It has no built-in CORS module. Everything is done with add_header directives and — regrettably — if blocks.

The Naive Approach (And Why It Breaks)

You'll find this snippet in approximately ten thousand Stack Overflow answers:

location /api/ {
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'Content-Type, Authorization';

    proxy_pass http://backend:8080;
}

This looks right. It has three problems:

  1. add_header doesn't work on error responses. If your backend returns a 502, 503, or 500, Nginx generates an error page and the add_header directives are silently dropped. Your frontend gets a CORS error instead of a useful error message.

  2. add_header in a nested block removes parent headers. If you add any add_header inside an if block or a nested location, Nginx drops all add_header directives from the parent context. This is documented, but it's the kind of documentation footnote that nobody reads until they've already been burned.

  3. Wildcard * doesn't work with credentials. If your frontend sends cookies or Authorization headers, the browser rejects Access-Control-Allow-Origin: *.

The if Block Pattern for Preflight

The standard pattern for handling CORS preflight in Nginx uses if, which Nginx's own documentation warns you about ("if is evil"). In this case, it's the least-bad option:

location /api/ {
    # Handle preflight requests
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Request-ID' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }

    # CORS headers for actual requests
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Expose-Headers' 'X-Request-ID, X-RateLimit-Remaining' always;
    add_header 'Vary' 'Origin' always;

    proxy_pass http://backend:8080;
}

Notice the always keyword on every add_header. This is the fix for problem #1: always makes Nginx include the header regardless of response status code — including 4xx and 5xx errors. Without always, a 502 Bad Gateway from your backend will arrive at the browser without CORS headers, and the browser will report a CORS error instead of showing you the actual problem.

This is the single most important Nginx CORS tip in this entire book: always use always.

Dynamic Origin Matching with map

Hardcoding a single origin works fine until you need to support staging, preview deployments, or multiple frontends. The map directive lets you dynamically select the Access-Control-Allow-Origin value:

# In the http {} block, BEFORE your server {} blocks
map $http_origin $cors_origin {
    default                          "";
    "https://app.example.com"        $http_origin;
    "https://staging.example.com"    $http_origin;
    "https://preview.example.com"    $http_origin;
}

# You can also use regex in map
map $http_origin $cors_origin_regex {
    default                                    "";
    ~^https://[a-z0-9-]+\.preview\.example\.com$  $http_origin;
    "https://app.example.com"                  $http_origin;
}

Then in your location block:

location /api/ {
    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' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Vary' 'Origin' always;
        return 204;
    }

    if ($cors_origin = "") {
        # Origin not in allowlist — don't add any CORS headers
        # The request still proxies, but the browser won't let JS read the response
    }

    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Expose-Headers' 'X-Request-ID' always;
    add_header 'Vary' 'Origin' always;

    proxy_pass http://backend:8080;
}

The map approach is clean and efficient — Nginx evaluates it as a hash table lookup (or regex match), not a chain of if statements.

Why Vary: Origin Is Non-Negotiable

When your Access-Control-Allow-Origin header changes based on the request's Origin header, you must include Vary: Origin in the response. Without it, intermediate caches (CDNs, browser cache, Nginx's own proxy cache) might serve a response cached for https://app.example.com to a request from https://staging.example.com. That cached response has the wrong origin in its CORS header, and the browser blocks it.

add_header 'Vary' 'Origin' always;

Even if you're not using a CDN today, add the Vary header anyway. Future-you will thank present-you when someone adds CloudFlare to the stack.

Full Nginx Configuration Example

Here's a complete, production-ready nginx.conf server block for a typical API:

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

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

    ssl_certificate     /etc/ssl/certs/api.example.com.pem;
    ssl_certificate_key /etc/ssl/private/api.example.com.key;

    # Proxy settings
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    location /api/ {
        # --- CORS Preflight ---
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin'  $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Accept, Authorization, Content-Type, X-Request-ID' always;
            add_header 'Access-Control-Max-Age'       86400 always;
            add_header 'Vary'                         'Origin' always;
            add_header 'Content-Length'                0;
            add_header 'Content-Type'                 'text/plain';
            return 204;
        }

        # --- CORS Response Headers ---
        add_header 'Access-Control-Allow-Origin'      $cors_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Expose-Headers'    'X-Request-ID, X-RateLimit-Remaining' always;
        add_header 'Vary'                             'Origin' always;

        # --- Proxy to backend ---
        proxy_pass http://127.0.0.1:8080;

        # Don't buffer responses — useful for streaming/SSE
        # proxy_buffering off;
    }

    # Health check — no CORS needed
    location /health {
        proxy_pass http://127.0.0.1:8080;
    }

    # Custom error pages — note these WON'T have CORS headers
    # even with 'always', because error_page generates a new response
    error_page 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
        internal;
    }
}

Testing Nginx CORS with curl

# Test preflight
curl -v -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/api/data

# Expected:
# < HTTP/2 204
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# < access-control-allow-headers: Accept, Authorization, Content-Type, X-Request-ID
# < access-control-max-age: 86400
# < vary: Origin

# Test actual request
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Expected:
# < access-control-allow-origin: https://app.example.com
# < access-control-allow-credentials: true
# < vary: Origin

# Test disallowed origin — should have no CORS headers
curl -v -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data

# Test that error responses include CORS headers (the 'always' keyword)
# Stop your backend, then:
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Should return 502 but STILL have CORS headers

Apache

Apache handles CORS through mod_headers, which is usually enabled by default. The configuration can live in the main server config, a <VirtualHost> block, a <Directory> block, or an .htaccess file.

Basic CORS with mod_headers

Minimal .htaccess for development:

Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization"

This has the same problems as the naive Nginx approach: no credentials support, no preflight handling, no error response coverage.

Header set vs Header always set

Apache has a critical distinction between Header set and Header always set:

  • Header set — adds the header only on successful responses (2xx, 3xx).
  • Header always set — adds the header on all responses, including 4xx and 5xx.

This is Apache's equivalent of Nginx's always keyword, and it matters for the exact same reason: without it, error responses lack CORS headers, and the browser shows a CORS error instead of the actual error.

# WRONG — error responses won't have CORS headers
Header set Access-Control-Allow-Origin "https://app.example.com"

# RIGHT — all responses get CORS headers
Header always set Access-Control-Allow-Origin "https://app.example.com"

Handling Preflight in Apache

Apache doesn't automatically respond to OPTIONS requests unless you configure it. You need to handle this explicitly:

# Enable mod_rewrite and mod_headers
RewriteEngine On

# Handle preflight OPTIONS requests
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]

# CORS headers for all responses
Header always set Access-Control-Allow-Origin "https://app.example.com"
Header always set Access-Control-Allow-Credentials "true"
Header always set Access-Control-Expose-Headers "X-Request-ID"
Header always set Vary "Origin"

# Additional headers only for preflight responses
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" env=REQUEST_METHOD
Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID" env=REQUEST_METHOD
Header always set Access-Control-Max-Age "86400" env=REQUEST_METHOD

Actually, that env-based approach is fragile. Here's a cleaner method using <If> directives (Apache 2.4+):

<If "%{REQUEST_METHOD} == 'OPTIONS'">
    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
    Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID"
    Header always set Access-Control-Max-Age "86400"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Vary "Origin"

    # Return 204 for preflight
    RewriteEngine On
    RewriteRule ^ - [R=204,L]
</If>

<Else>
    Header always set Access-Control-Allow-Origin "https://app.example.com"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Access-Control-Expose-Headers "X-Request-ID, X-RateLimit-Remaining"
    Header always set Vary "Origin"
</Else>

Dynamic Origin in Apache

Apache can dynamically set the origin using SetEnvIf and conditional headers:

# Check if Origin matches our allowlist
SetEnvIf Origin "^https://(app|staging)\.example\.com$" CORS_ORIGIN=$0

# Only set CORS headers if origin is allowed
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Vary "Origin" env=CORS_ORIGIN

The env=CORS_ORIGIN at the end means the header is only added if the environment variable CORS_ORIGIN is set — which only happens if the SetEnvIf regex matched. This is Apache's equivalent of Nginx's map directive.

Gotcha: The regex in SetEnvIf is matched against the full header value, but it's not anchored by default. Use ^ and $ explicitly, or you'll match https://notapp.example.com too.

Full Apache .htaccess Example

# ==============================================
# CORS Configuration — Production
# ==============================================

# Ensure mod_headers and mod_rewrite are available
<IfModule mod_headers.c>
<IfModule mod_rewrite.c>

    RewriteEngine On

    # --- Dynamic origin allowlist ---
    SetEnvIf Origin "^https://(app|staging)\.example\.com$" CORS_ALLOW=true CORS_ORIGIN=$0

    # --- Preflight handling ---
    # Match OPTIONS requests from allowed origins
    RewriteCond %{REQUEST_METHOD} =OPTIONS
    RewriteCond %{ENV:CORS_ALLOW} =true
    RewriteRule ^ - [R=204,L]

    # --- CORS headers for allowed origins ---

    # Always set on all responses (including errors)
    Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ALLOW
    Header always set Access-Control-Allow-Credentials "true" env=CORS_ALLOW
    Header always set Vary "Origin"

    # Preflight-specific headers
    # These get added to the 204 response from the rewrite rule above
    Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" env=CORS_ALLOW
    Header always set Access-Control-Allow-Headers "Accept, Authorization, Content-Type, X-Request-ID" env=CORS_ALLOW
    Header always set Access-Control-Max-Age "86400" env=CORS_ALLOW

    # Expose custom response headers
    Header always set Access-Control-Expose-Headers "X-Request-ID, X-RateLimit-Remaining" env=CORS_ALLOW

</IfModule>
</IfModule>

Testing Apache CORS with curl

# Preflight from allowed origin
curl -v -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  https://api.example.com/api/data

# Expected:
# < HTTP/1.1 204 No Content
# < Access-Control-Allow-Origin: https://app.example.com
# < Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# < Access-Control-Allow-Headers: Accept, Authorization, Content-Type, X-Request-ID
# < Access-Control-Max-Age: 86400
# < Vary: Origin

# Actual request
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Disallowed origin — no CORS headers should appear
curl -v -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data

Common Pitfalls (Both Servers)

Pitfall 1: Duplicate CORS Headers

This is the #1 problem I see with reverse-proxy CORS setups. It happens when your backend application also adds CORS headers, and then Nginx or Apache adds them again. The response ends up with:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: https://app.example.com

Two Access-Control-Allow-Origin headers. Some browsers handle this gracefully. Chrome, however, treats it as an error — even if both values are identical. The error message in DevTools is magnificently unhelpful:

Access to XMLHttpRequest at 'https://api.example.com/api/data' 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.

The fix: Pick ONE layer to handle CORS. Either:

  • Configure CORS in your application and let the proxy pass headers through untouched.
  • Configure CORS in the proxy and strip/disable CORS in your application.

If you choose the proxy approach in Nginx, remove any CORS headers the backend sends:

location /api/ {
    # Strip CORS headers from the upstream response
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Methods;
    proxy_hide_header Access-Control-Allow-Headers;
    proxy_hide_header Access-Control-Allow-Credentials;
    proxy_hide_header Access-Control-Expose-Headers;
    proxy_hide_header Access-Control-Max-Age;

    # Then add our own
    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    # ... rest of CORS headers ...

    proxy_pass http://backend:8080;
}

In Apache:

# Remove upstream CORS headers before adding our own
Header always unset Access-Control-Allow-Origin
Header always unset Access-Control-Allow-Methods
Header always unset Access-Control-Allow-Headers
Header always unset Access-Control-Allow-Credentials

# Then add ours
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ALLOW
# ... rest of CORS headers ...

Pitfall 2: Error Pages Without CORS Headers

You stopped your backend. Nginx returns a 502 Bad Gateway. You used always on your add_header directives, so the CORS headers are there, right?

Maybe. If Nginx's error_page directive kicks in and serves a static HTML page, those add_header directives in your location /api/ block don't apply to the error page response. The error page is served from a different context.

In Nginx, verify with curl:

# Stop your backend, then:
curl -v -H "Origin: https://app.example.com" \
  https://api.example.com/api/data

# Check for CORS headers in the 502 response
# If they're missing, your error_page directive is overriding them

Fix for Nginx: Add CORS headers in the server block level (not just location), or use a named location for error handling that includes CORS headers:

error_page 502 503 504 = @cors_error;

location @cors_error {
    add_header 'Access-Control-Allow-Origin' $cors_origin always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Vary' 'Origin' always;
    add_header 'Content-Type' 'application/json' always;
    return 502 '{"error": "upstream unavailable"}';
}

Pitfall 3: Caching Without Vary: Origin

If you use a CDN (CloudFlare, Fastly, CloudFront) or Nginx's proxy_cache in front of your API, and you don't include Vary: Origin, here's what happens:

  1. User A visits from https://app.example.com. The CDN caches the response with Access-Control-Allow-Origin: https://app.example.com.
  2. User B visits from https://staging.example.com. The CDN serves the cached response — which has the wrong origin header.
  3. User B's browser rejects the response with a CORS error.

This is intermittent, which makes it infuriating to debug. It depends on which user hits the cache first, which CDN edge node they're on, and when the cache expires. You'll see bug reports like "CORS works sometimes but not always" and question your sanity.

The fix is to always include Vary: Origin when the response headers depend on the request's Origin:

add_header 'Vary' 'Origin' always;
Header always set Vary "Origin"

If you already have other Vary values (like Accept-Encoding), you can append:

add_header 'Vary' 'Origin, Accept-Encoding' always;
Header always append Vary "Origin"

Note: Apache's Header append adds the value to an existing Vary header rather than replacing it. Nginx doesn't have an "append" mode for add_header, so you need to include all Vary values in a single directive.

Pitfall 4: Using if in Nginx for Non-Preflight Logic

Nginx's if directive has well-documented unexpected behavior. Specifically, if you put add_header inside an if block, all add_header directives from the parent context are dropped. This means:

location /api/ {
    add_header 'X-Custom-Header' 'hello' always;        # This header...
    add_header 'Vary' 'Origin' always;                   # And this one...

    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';    # These are the ONLY
        add_header 'Access-Control-Allow-Methods' 'GET'; # headers you get
        return 204;                                      # on OPTIONS responses
    }

    # X-Custom-Header and Vary are present on non-OPTIONS responses
    # but ABSENT on OPTIONS responses
    proxy_pass http://backend:8080;
}

The X-Custom-Header and Vary headers disappear from OPTIONS responses because the if block creates a new context. This is why every example in this chapter repeats the Vary: Origin header inside the if block.

Pitfall 5: Proxy Rewriting the Origin Header

Some Nginx configurations inadvertently strip or modify the Origin request header before it reaches the backend. If your backend handles CORS (instead of the proxy), make sure the Origin header is forwarded:

# This is usually fine — proxy_pass forwards most headers by default
proxy_pass http://backend:8080;

# But if you're overriding headers, make sure Origin is included:
proxy_set_header Origin $http_origin;

Quick Decision Guide: Proxy vs Application CORS

Handle CORS in...When to use it
The applicationYou need per-route CORS policies, your app has clear ownership, or you're using a framework with good CORS middleware
The proxyYour backend doesn't support CORS natively, you have multiple backends behind one proxy, or you need consistent CORS across all services
BothNever. Just... don't. Duplicate headers await.

The safest approach for most teams: handle CORS in the application, and don't touch it in the proxy. This keeps the CORS logic close to the code that defines the API, version-controlled alongside the application, and testable in development without a proxy in the loop.

The exception is when you're running third-party backends that you can't modify. In that case, the proxy is your only option — and proxy_hide_header (Nginx) or Header always unset (Apache) are your friends.


Verifying Your Configuration

After setting up CORS at the proxy layer, run through this checklist with curl:

# 1. Preflight from allowed origin — should get full CORS headers
curl -sv -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/api/data 2>&1 | grep -i "access-control\|vary"

# 2. Simple request from allowed origin — should get CORS headers
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/data 2>&1 | grep -i "access-control\|vary"

# 3. Request from disallowed origin — should NOT have CORS headers
curl -sv -H "Origin: https://evil.example.com" \
  https://api.example.com/api/data 2>&1 | grep -i "access-control"

# 4. Error response — should STILL have CORS headers
# (stop your backend or hit a known-bad endpoint)
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/definitely-not-found 2>&1 | grep -i "access-control\|vary"

# 5. Check for duplicate headers
curl -sv -H "Origin: https://app.example.com" \
  https://api.example.com/api/data 2>&1 | grep -ci "access-control-allow-origin"
# Should output: 1 (not 2)

If all five checks pass, you're in good shape. If check 5 returns 2, go read the "Duplicate CORS Headers" section again. I'll wait.