Troubleshooting CORS at the Proxy Layer

A CORS error whose root cause is not in your application is one of the most disorienting failures to debug: your server code emits Access-Control-Allow-Origin correctly, your unit tests pass, curl against localhost looks perfect — yet the browser still reports the request as blocked. The reason is that between your application and the browser sits at least one intermediary — a reverse proxy, an application load balancer, a CDN, an API gateway, or a service-mesh sidecar — and any of them can strip, duplicate, or drop the very headers CORS depends on. Because the browser performs an exact string comparison on the headers it actually receives, a header that was correct at the origin but mutated in transit fails identically to one that was never sent.

This page is part of Cross-Origin Debugging & Error Diagnosis, which covers reading browser errors, inspecting preflight in DevTools, and reproducing failures with curl. Here the focus narrows to the network path itself: how to prove an intermediary is at fault, and how to fix the specific layer responsible.

The Problem: Correct Headers That Never Arrive

The WHATWG Fetch Standard (§3.2, CORS check) defines the browser’s decision as a comparison against the Access-Control-Allow-Origin value present on the response it receives. The specification says nothing about how many hops the response crossed — it only sees the final headers. This creates a class of failures that are invisible from the application’s perspective:

The unifying symptom: the failure follows the deployment topology, not the code. It reproduces through the proxy and vanishes when you hit the origin directly. That asymmetry is both the diagnosis and the fix strategy.

Spec Anchor: What the Network Path Must Preserve

Per the Fetch Standard’s CORS-preflight fetch (§4.8) and the CORS check (§3.2), the response the browser evaluates must carry, unchanged from the origin’s intent:

Every intermediary is obligated either to pass these through untouched or to synthesise a complete, correct set itself. A layer that does neither — passing some through while overwriting or dropping others — is the source of most proxy-layer CORS bugs.

Reference: Proxy-Layer Failure Modes

Failure mode Where it happens Observable symptom Fix
add_header without always Nginx Header present on 200, absent on 4xx/5xx Append always to every add_header Access-Control-*
Double CORS (both layers add the header) ALB + app, Nginx + app Access-Control-Allow-Origin header appears twice or as a comma list Disable CORS on one layer; proxy_hide_header the other’s copy
Header stripped in transit Service mesh, minimal proxy Header present at origin, absent at edge Add explicit header pass-through / allowlist rule
WAF blocks OPTIONS Cloudflare, ALB WAF, ModSecurity Preflight returns 403; real request never fires Add WAF exception for OPTIONS on the path prefix
Credentials header dropped CDN, API gateway Credentialed request blocked though origin is allowed Ensure Access-Control-Allow-Credentials survives every hop
Vary: Origin missing on cached response CDN edge cache One origin served another origin’s Allow-Origin Add Vary: Origin; bypass cache for credentialed requests
Preflight cached and replayed to wrong origin CDN Preflight 204 served with a stale, mismatched origin grant Vary the cache key on Origin or bypass cache for OPTIONS
Gateway rewrites Origin or Host API gateway, mesh Origin allowlist never matches Preserve the original Origin header end-to-end

Where Headers Get Mutated Along the Path

The diagram traces a request from the browser through a typical multi-layer path — CDN, load balancer, reverse proxy, application — and marks the two points where CORS headers are most often lost or duplicated.

Where CORS headers get mutated along the proxy path A left-to-right lane showing Browser, CDN or WAF, Load Balancer, Reverse Proxy, and Application. The application emits correct CORS headers. Annotations mark the WAF blocking OPTIONS with 403, the load balancer and reverse proxy each risking a duplicate or dropped Access-Control-Allow-Origin header, and the CDN risking a cached preflight served to the wrong origin. Response headers travel right-to-left back to the browser Browser CDN / WAF Load Balancer Reverse Proxy Application emits headers ✓ WAF may 403 the OPTIONS preflight cached preflight → wrong origin a second layer adding the header here duplicates or drops Access-Control-Allow-Origin Bisect: curl the Application directly, then curl the CDN edge, and compare

Step-by-Step: Bisect the Path, Then Fix the Layer

Step 1 — Confirm the Origin Is Correct

Reach the application directly, bypassing every proxy. Use --resolve or an internal address so DNS does not send you back through the CDN:

# Hit the app's own listener directly (internal IP or --resolve override)
curl -sS -D - -o /dev/null \
  --resolve api.example.com:8080:10.0.3.14 \
  -X OPTIONS "http://api.example.com:8080/v1/resource" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type"

Confirm the origin returns Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. If they are missing here, the problem is in the application, not the proxy — start with Server-Side CORS Configuration & Header Management.

Step 2 — Reproduce Through the Public Edge

Run the identical request against the public hostname that traverses the full proxy stack:

curl -sS -D - -o /dev/null \
  -X OPTIONS "https://api.example.com/v1/resource" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type"

Diff the two responses. A missing header means a layer stripped it; two copies mean a layer duplicated it; a 403 means a WAF blocked the OPTIONS. For a deeper treatment of building these requests, see Reproducing CORS Failures with curl.

Step 3 — Walk Inward to Isolate the Hop

If the edge and origin disagree, test each intermediate layer’s own address in turn — load balancer, then reverse proxy — using --resolve to pin each hop. The first layer whose outbound headers differ from its inbound headers is the offender. Server-identifying response headers (for example a backend Server: value that should not survive edge termination) and per-hop request IDs help you confirm which layer answered.

Step 4 — Fix Nginx add_header Drops on Errors

If headers appear on 200 but vanish on 4xx/5xx, add the always flag. Without it, Nginx omits add_header output for error status codes:

location /v1/ {
  proxy_pass http://backend_upstream;

  # `always` emits the header on every status, including 4xx/5xx
  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 Vary                         "Origin" always;
}

Step 5 — Resolve Double CORS (ALB + App)

If two layers each add Access-Control-Allow-Origin, disable it on one. Keep CORS on the layer that can allowlist-match the Origin, and strip the other layer’s copy so no duplicate survives:

location /v1/ {
  proxy_pass http://backend_upstream;

  # Remove the app's CORS headers so only the proxy's copy remains
  proxy_hide_header Access-Control-Allow-Origin;
  proxy_hide_header Access-Control-Allow-Credentials;

  add_header Access-Control-Allow-Origin      $cors_origin always;
  add_header Access-Control-Allow-Credentials "true" always;
  add_header Vary "Origin" always;
}

The full walkthrough of the duplicate case lives in Fixing Duplicate Access-Control-Allow-Origin Headers.

Step 6 — Add a WAF Exception for OPTIONS

If the preflight returns 403, the WAF rejected OPTIONS before routing. Add a scoped exception that allows OPTIONS on the API path prefix while leaving other methods governed by the rule set. On a managed edge such as Cloudflare, this is a WAF skip rule; the Cloudflare-specific procedure is in Debugging CORS Failures Behind Cloudflare.

Edge Cases and Security Boundaries

Do not “fix” a stripped header by reflecting every origin. The temptation when a proxy drops Access-Control-Allow-Origin is to have the proxy echo $http_origin unconditionally. That turns the API into one that trusts any browser page. Reflect only allowlist-matched origins; see Dynamic Origin Validation Patterns and Wildcard Risks & Mitigation.

The null origin appears from sandboxed iframes, data: URLs, and some redirects. A proxy that reflects it grants those contexts access. Treat null as unmatched unless you deliberately support such flows.

Credentialed requests forbid *. If a layer downgrades an exact origin to Access-Control-Allow-Origin: * while the request carries cookies, the browser rejects the response. Ensure the exact origin and Access-Control-Allow-Credentials: true survive every hop together — see Credential Sharing & Security Boundaries.

Cache scoping. A shared cache that stores a CORS response without Vary: Origin can serve one origin’s grant to another. This is both a correctness bug and a boundary leak; keep Vary: Origin on every cached CORS response.

Proxy / CDN Interaction

When a preflight is terminated at the edge (see Proxy Bypass Strategies), the origin never emits CORS headers for that OPTIONS, so duplication is impossible there — but the edge now owns correctness entirely, including Vary: Origin and cache-key scoping. When the edge instead passes OPTIONS through, every intermediate layer must preserve the headers untouched. Mixing the two models — one layer terminating some preflights while another adds headers to the rest — is the most common way double-CORS bugs creep in after an infrastructure change.

DevTools + curl Verification Checklist

Common Mistakes

Issue Technical impact Mitigation
add_header without always in Nginx CORS headers vanish on 4xx/5xx, so error responses are blocked and mask the real fault Append always to every add_header Access-Control-* directive
CORS enabled on both ALB and application Duplicate Access-Control-Allow-Origin header; browser rejects the response Disable CORS on one layer; proxy_hide_header the other’s copy
WAF blocks OPTIONS before routing Preflight returns 403; browser shows a generic CORS error Add a scoped WAF exception permitting OPTIONS on the path prefix
Debugging only through the proxy The origin’s correct config is invisible; you chase the wrong layer Always curl the origin directly first, then the edge, and diff
Reflecting $http_origin to “restore” a stripped header API now trusts every origin, including attacker pages Reflect only allowlist-matched origins
Gateway rewrites the Origin or Host header The application’s allowlist never matches the real origin Preserve the original Origin header end-to-end

FAQ

The app returns correct CORS headers but the browser still blocks the request. Where do I look?

Bisect the request path. Run curl directly against the application origin, then run the same curl against the public proxy hostname. If the headers are present on the origin response but absent, duplicated, or altered on the proxy response, an intermediary is mutating them. The layer where the headers change is the layer to fix.

Why do CORS headers appear on 200 responses but disappear on 404 or 500?

This is the classic Nginx add_header without always drop. Nginx only emits add_header directives for a defined set of successful status codes unless you append the always flag. On 4xx and 5xx responses the Access-Control-Allow-Origin header is omitted, so the browser blocks the error response and the developer sees a CORS error masking the real failure.

Both my load balancer and my application add CORS headers. Which one should I disable?

Disable one, never both. Emitting Access-Control-Allow-Origin from two layers produces a duplicate header the browser rejects. Pick the layer that already sees the Origin request header and can allowlist-match it, disable CORS entirely on the other, and strip any residual upstream Access-Control headers at the surviving layer.

Why does my WAF return 403 for OPTIONS preflight requests?

Many WAF and managed rule sets treat OPTIONS as anomalous because it is uncommon on non-CORS traffic, and some rules inspect for missing bodies or unusual methods. The 403 arrives before the request reaches the application, so the preflight fails and the browser reports a generic CORS error. Add an explicit WAF exception allowing OPTIONS on the affected path prefix.

How do I tell which proxy layer stripped a header when there are several?

Walk the path inward one hop at a time. Curl the innermost origin, then each successive layer’s internal address, then the public edge. The first hop where the header is present on the inbound side but missing on the outbound side is the culprit. Server-identifying response headers and per-hop request IDs help you confirm which layer answered.

Topics in This Section