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
- The upstream app already answers
OPTIONSwith a correct204and the requiredAccess-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers— verify this by hitting the app directly, bypassing Nginx. - You have edit access to the Nginx
server/locationconfig and can reload withnginx -s reload. - You understand this page delegates CORS to the app; if you would rather answer
OPTIONSat the edge, see the Nginx termination example in Proxy Bypass Strategies.
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.
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.