Debugging CORS Failures Behind Cloudflare

The symptom is specific and maddening: your API sends correct CORS headers, curl against the origin proves it, but the moment traffic goes through Cloudflare the browser reports the request as blocked — sometimes intermittently, sometimes only for a second origin, sometimes only for credentialed requests.

Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://admin.example.com' has been blocked by CORS policy: The
'Access-Control-Allow-Origin' header has a value 'https://app.example.com'
that is not equal to the supplied origin.

Root Cause

Cloudflare is a caching, transforming, filtering edge between the browser and your origin, and any of those three functions can break CORS while your origin stays correct. The dominant causes are: an edge cache that stored a CORS response without Vary: Origin and now serves one origin’s Access-Control-Allow-Origin to a different origin; a Transform Rule that rewrites or drops response headers; a WAF rule that blocks the OPTIONS preflight with a 403 before it reaches your origin; and a cached preflight replayed to the wrong requester. In every case the WHATWG Fetch Standard’s CORS check (§3.2) evaluates the header the browser actually receives from the edge — not the one your origin sent — so a correct origin is not enough.

Mapping the three edge subsystems onto the response path shows why a correct origin proves nothing on its own:

Three edge subsystems that can rewrite a CORS response A response leaves a correctly configured origin and passes through the Cloudflare edge on its way to the browser. Inside the edge, the cache, the Transform Rules engine and the WAF each have a documented way of changing or suppressing the Access-Control headers the browser finally sees. Three places the edge can change a CORS response Browser reads headers Cloudflare edge Edge cache serves one origin's grant to another Transform Rules rewrites or drops Access-Control headers WAF custom and managed rules answers the OPTIONS preflight with 403 Origin correct CORS The browser judges the headers the edge returns, never the ones your origin sent

This page is part of Troubleshooting CORS at the Proxy Layer, which covers the full set of intermediary-induced CORS failures.

Prerequisite State

Step-by-Step Fix

Step 1 — Curl the Origin vs the Edge

Compare the two responses side by side. Pin the origin request to your origin IP so it skips Cloudflare:

# Direct to origin (skip Cloudflare)
curl -sI --resolve api.example.com:443:203.0.113.10 \
  "https://api.example.com/v1/orders" \
  -H "Origin: https://admin.example.com" | grep -iE 'allow-origin|vary'

# Through the Cloudflare edge
curl -sI "https://api.example.com/v1/orders" \
  -H "Origin: https://admin.example.com" \
  | grep -iE 'allow-origin|vary|cf-cache-status'

If the origin returns the right Access-Control-Allow-Origin but the edge returns a different value (or none), Cloudflare mutated it.

Step 2 — Check CF-Cache-Status

A HIT on a CORS response means the edge served a stored copy — which may name a different origin than the current request:

curl -sI "https://api.example.com/v1/orders" \
  -H "Origin: https://admin.example.com" \
  | grep -i '^cf-cache-status'
# cf-cache-status: HIT  → a cached, possibly mis-scoped preflight/response

Step 3 — Add Vary: Origin

Tell the edge cache to key responses per origin so it never serves app.example.com’s grant to admin.example.com. Emit Vary: Origin from your origin, or add it with a Cloudflare Response Header Transform Rule:

# Cloudflare → Rules → Transform Rules → Modify Response Header
When incoming requests match:  (http.request.uri.path contains "/v1/")
Then:  Set static  Vary: Origin

The header changes what the edge stores under, so two requests that used to collide now land in separate slots:

Cache keys with and without Vary: Origin Two panels trace the same pair of requests through the edge cache. Without Vary the key is method plus URL, so the second origin gets a HIT carrying the first origin's grant. With Vary the Origin header joins the key, so the second request misses and is answered by the origin with its own grant. How the edge cache key decides which grant you get No Vary: one entry for the path Vary: Origin, one entry per origin request 1 sends Origin: app.example.com cache key = method + URL stored: allow-origin = app.example.com request 2 sends Origin: admin.example.com same key, so the edge answers with a HIT served: allow-origin = app.example.com the value does not match the supplied origin request 1 sends Origin: app.example.com cache key = method + URL + Origin stored: allow-origin = app.example.com request 2 sends Origin: admin.example.com different key, so the origin is consulted served: allow-origin = admin.example.com each origin receives its own grant Vary: Origin turns one shared entry into one entry per requesting origin

For the underlying reasoning on this header, see Handling the Vary: Origin Header Correctly.

Step 4 — Add a WAF Exception for OPTIONS

If the preflight returns 403 through the edge but 204/200 at the origin, a WAF rule is blocking OPTIONS. Add a skip rule so preflights pass:

# Cloudflare → Security → WAF → Custom rules
When:  (http.request.method eq "OPTIONS" and http.request.uri.path contains "/v1/")
Then:  Skip → All remaining custom rules  (and managed rules as needed)

The skip rule changes which hop terminates the preflight, and that is the difference the browser reports as a CORS failure:

Where the OPTIONS preflight terminates The upper chain shows the browser sending an OPTIONS preflight that a WAF custom rule answers with 403, so the origin is never reached and the browser reports a CORS error. The lower chain shows the same preflight after a skip rule, reaching the origin and returning 204 with the CORS headers. Where the OPTIONS preflight dies when a WAF rule matches Before: a custom rule matches OPTIONS Browser OPTIONS preflight Cloudflare WAF rule matches, returns 403 Origin never reached Browser CORS error After: a skip rule lets OPTIONS through Browser OPTIONS preflight Cloudflare WAF skip rule, passes through Origin 204 with headers Browser preflight OK The origin access log stays silent in the first row, and that silence is the tell

Step 5 — Bypass Cache for Credentialed and OPTIONS Requests

Credentialed CORS responses must not be shared between origins or users. Add a Cache Rule that bypasses cache for preflights and credentialed traffic:

# Cloudflare → Caching → Cache Rules
When:  (http.request.method eq "OPTIONS")
       or (any(http.request.headers["cookie"][*] != ""))
       or (any(http.request.headers["authorization"][*] != ""))
Then:  Cache eligibility → Bypass cache

Verification

# Edge now returns the requesting origin's own grant, with Vary and a cache bypass
curl -sI "https://api.example.com/v1/orders" \
  -H "Origin: https://admin.example.com" \
  | grep -iE 'allow-origin|vary|cf-cache-status'
# Expect: Access-Control-Allow-Origin: https://admin.example.com
#         Vary: Origin
#         cf-cache-status: DYNAMIC  (or BYPASS)

Repeat the request from a second allowed origin and confirm each receives its own value. In Chrome DevTools → Network, disable the cache, reload, and confirm the preflight OPTIONS returns 204/200 with the matching Access-Control-Allow-Origin, not a 403 or a stale grant.

Security Boundary Note

Do not “solve” Cloudflare caching by reflecting every Origin and caching the result — that combination lets the edge hand any origin a valid grant. If you reflect origins, you must allowlist-match them at the origin server and add Vary: Origin so the cache scopes per origin. And never pair Access-Control-Allow-Origin: * with credentials: the Fetch spec forbids it, and a credentialed response served from a shared edge cache is a cross-user data-exposure risk. For the allowlisting model, see Dynamic Origin Validation Patterns; for the credential rules, see Credential Sharing & Security Boundaries.

Common Mistakes

Issue Technical impact Mitigation
Caching CORS responses without Vary: Origin Edge serves one origin’s Access-Control-Allow-Origin to another; the CORS check fails Emit Vary: Origin or bypass cache for CORS responses
WAF blocks OPTIONS Preflight returns 403; browser reports a generic CORS error Add a WAF skip rule for OPTIONS on the API path
Transform Rule drops or rewrites Access-Control-* Header the origin set never reaches the browser Audit Transform Rules; preserve Access-Control-* and Vary
Caching credentialed responses A stored credentialed response is reused across origins or users Bypass cache when a Cookie or Authorization header is present

FAQ

Why does CORS work when I hit my origin directly but fail through Cloudflare?

Cloudflare sits between the browser and your origin and can cache, transform, or block responses. The most common causes are an edge cache that stored one origin’s Access-Control-Allow-Origin and serves it to another because Vary: Origin was missing, a Transform Rule that alters headers, or a WAF rule that blocks the OPTIONS preflight. The origin is correct; the edge changed the response the browser actually receives.

How do I stop Cloudflare from caching a preflight for the wrong origin?

Add Vary: Origin to the response so the cache keys per origin, and bypass the cache for credentialed or OPTIONS requests with a Cache Rule. A cached Access-Control-Allow-Origin value is only safe to reuse for the exact origin it was generated for, so either vary on Origin or do not cache the CORS response at all.

What does a CF-Cache-Status of HIT mean for a CORS response?

It means Cloudflare served the response from its edge cache rather than your origin. For a CORS response that is dangerous unless the cache key includes Origin, because a HIT can return a stored Access-Control-Allow-Origin that names a different origin than the current request. Check CF-Cache-Status in the response headers; a HIT on a CORS response is a signal to add Vary: Origin or bypass the cache.