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.

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

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)

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.