Delegating Preflight to Nginx Upstreams

Failure symptom:

Access to fetch at 'https://api.example.com/v1/orders' 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.

Your upstream application already implements CORS correctly, but the response arrives at the browser with a duplicated Access-Control-Allow-Origin — because Nginx is also adding it. You want the upstream to own CORS, and Nginx to stay out of the way.

Root Cause

When Nginx reverse-proxies to an application that emits its own Access-Control-* headers, and the Nginx config also contains add_header Access-Control-Allow-Origin ..., both values reach the browser. The WHATWG Fetch Standard (§4.10, CORS check) treats Access-Control-Allow-Origin as a single-value header: two identical values still fail the check, because the header value https://app.example.com, https://app.example.com is not byte-for-byte equal to the request’s origin. Delegation means Nginx must transport the OPTIONS request and the upstream’s response verbatim, adding nothing and stripping nothing that carries CORS meaning.

This page is part of Proxy Bypass Strategies for CORS Preflight, which contrasts edge-terminating OPTIONS at the proxy with passing it through to the upstream.

Prerequisite State

Step-by-Step Fix

Step 1 — Confirm the upstream owns CORS (bypass Nginx)

Hit the application’s listen address directly so Nginx is not in the path. If these headers are correct, the upstream is authoritative and Nginx must not touch them.

curl -sI -X OPTIONS http://127.0.0.1:8080/v1/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST'

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

Step 2 — Strip CORS directives from the Nginx location

Delegation means Nginx adds nothing. Remove every add_header Access-Control-* from the proxying location. The block below passes all methods — including OPTIONS — straight to the upstream and preserves the Origin header.

upstream app_backend {
    server 127.0.0.1:8080;
}

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

    location /v1/ {
        # Delegate CORS entirely to the upstream: NO add_header here.
        proxy_pass http://app_backend;

        # Preserve the headers the app needs to validate the request
        proxy_set_header Host              $host;
        proxy_set_header Origin            $http_origin;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

OPTIONS is not special-cased, so it flows through proxy_pass like any other method and the upstream’s 204 is returned unmodified. The explicit proxy_set_header Origin $http_origin is defensive: it guarantees the app sees the original Origin even if an upstream include re-maps request headers.

Step 3 — Neutralise CORS headers injected by an inherited config

If a parent http {} or a shared snippet emits Access-Control-Allow-Origin on the upstream response and you cannot remove it there, strip it from the upstream response with proxy_hide_header so only the app’s own value — re-added by the app, not Nginx — survives. Use this only to remove a stray injected header, not the app’s:

location /v1/ {
    proxy_pass http://app_backend;
    proxy_set_header Origin $http_origin;

    # Only if an inherited config wrongly injects these; otherwise omit.
    # This hides the header from the CLIENT-facing response. When the app
    # is the sole legitimate source, ensure Nginx itself never add_headers it.
    # proxy_hide_header Access-Control-Allow-Origin;
}

proxy_hide_header prevents a named upstream response header from being passed to the client. Reach for it only when a duplicate is coming from an Nginx layer you do not control; in a clean delegation (Step 2) it is unnecessary because Nginx adds nothing.

Delegating preflight: pass-through vs double-header Top path shows Nginx passing OPTIONS to the upstream app, which returns a 204 with a single Access-Control-Allow-Origin that Nginx forwards unmodified. Bottom path shows the broken case where Nginx also runs add_header, producing two values that the browser rejects. Delegation (correct) Browser Nginx no add_header Upstream app owns CORS OPTIONS pass-through 204, one Allow-Origin Both layers add header (broken) Browser Nginx add_header ACAO Upstream app also sets ACAO two values, browser blocks

Verification

Through Nginx — confirm the preflight reaches the upstream and returns a single header value:

curl -sI -X OPTIONS https://api.example.com/v1/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST'

Then count the header to prove there is no duplication:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' | grep -ci 'access-control-allow-origin:'

Expected: 1. Check the upstream’s access log shows the OPTIONS entry — that confirms delegation (the app handled it) rather than edge termination. In Chrome DevTools, filter the Network panel by Preflight and confirm a single Access-Control-Allow-Origin on the OPTIONS row.

Security Boundary Note

Delegation moves origin validation to the upstream, so the upstream must enforce a strict allowlist — do not let it reflect an arbitrary Origin. Preserving the Origin header with proxy_set_header Origin $http_origin is safe, but never add proxy_set_header Origin "https://app.example.com" to fake a trusted origin: that masks the real client origin from the app and defeats its validation. Keep exactly one CORS authority. For how the upstream should validate origins, see Dynamic Origin Validation Patterns; for why duplicate headers fail even when both values match, see Header Deduplication Techniques.

Common Mistakes

Issue Technical impact Mitigation
add_header Access-Control-* left in the proxying location Nginx and the app both emit the header; browser rejects the duplicated value Remove all add_header Access-Control-* from the delegating location
Overwriting Origin with proxy_set_header Origin "..." App validates a forged origin and loses the real one Pass the real value with proxy_set_header Origin $http_origin, or omit it
Special-casing OPTIONS with return 204 while delegating Nginx terminates preflight itself, contradicting delegation and skipping the app’s logic Let OPTIONS flow through proxy_pass like any other method
Using proxy_hide_header on the app’s legitimate header The only correct CORS header is stripped, and the browser blocks the request Apply proxy_hide_header only to a stray header from an inherited config

FAQ

Should Nginx or the upstream app answer the OPTIONS preflight?

Either works, but pick one. Delegating to the upstream keeps CORS logic in one place next to your route definitions and origin allowlist, which is easier to keep in sync. Terminating at Nginx is faster because the app is never invoked. The failure mode to avoid is both layers adding Access-Control headers, which produces duplicates the browser rejects.

Does Nginx forward the Origin header to the upstream by default?

Yes. Nginx passes request headers including Origin to the upstream unless you clear them. The header commonly lost is Host, not Origin. If your app validates Origin and sees it missing, check for a proxy_set_header that overwrites it or a security module that strips it, rather than assuming Nginx dropped it.

How do I stop duplicate Access-Control-Allow-Origin headers when delegating?

When delegating, Nginx must add no CORS headers of its own — remove every add_header Access-Control-* from the proxying location. If an inherited config injects a stray Access-Control-Allow-Origin onto the upstream response, strip that one with proxy_hide_header. The goal is that exactly one layer, the upstream app, emits the header.