CORS Errors Behind a CDN: Causes and Header Fixes

A CORS error behind a CDN is almost never a browser bug and almost never an origin bug. It is a cache key problem. The origin returns the correct Access-Control-Allow-Origin for whoever asked first, the edge stores that response without Vary: Origin, and every later visitor from a different origin receives a header addressed to somebody else. Add the Fetch standard's 5-second default preflight lifetime when Access-Control-Max-Age is absent, and you get failures that appear on roughly one request in ten and vanish the instant someone bypasses the edge to test the origin directly.

Diagram of CORS headers between a browser, a CDN edge cache and an origin, showing Vary Origin and preflight caching for CDN CORS headers

What actually causes CORS errors behind a CDN

Three failure classes account for nearly everything reported as a "CDN CORS problem". They have different symptoms and different fixes, and conflating them is why teams spend a week adding wildcards that make things worse.

Cause 1: the edge never forwards the Origin request header

Most CDNs strip or ignore request headers that are not part of the cache key, because forwarding everything destroys cacheability. If the origin computes Access-Control-Allow-Origin by reflecting the incoming Origin, and the Origin header never arrives, the origin either omits the header entirely or returns a null value. The browser then blocks the response with no useful diagnostic beyond the generic console message.

Cause 2: Access-Control-Allow-Origin cached without Vary: Origin

This is the dangerous one. The origin reflects the Origin header correctly, the edge caches the full response including the reflected Access-Control-Allow-Origin, and no Vary: Origin is present. Now one cached object serves an allow-origin value belonging to a single tenant. Tenant A works, tenants B through Z get a CORS error behind a CDN edge that is doing exactly what it was told. Worse, if the response is credentialed, you have leaked a cross-tenant response into a shared cache entry.

Cause 3: preflights that never reach the origin, or reach it every time

A preflight is an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers. Two opposite misconfigurations are common: the edge answers OPTIONS itself with a canned response that omits the headers your actual request needs, or the edge treats OPTIONS as uncacheable and forwards every single one. Any custom header — an Authorization variant, an X-Request-Id, or a Range header on video — pushes the request out of the safelisted set and forces that round trip.

How Vary and preflight caching interact

Vary: Origin and Access-Control-Max-Age control two independent caches, and engineers routinely tune one while the other dominates the observed behaviour. Vary governs the shared cache at the edge and the private cache in the browser. Access-Control-Max-Age governs only the browser's preflight result cache, which is keyed per requesting origin, URL and credentials mode.

Browsers clamp the value you send. Chromium enforces a ceiling of 7200 seconds on Access-Control-Allow-Max-Age handling, Firefox permits up to 86400 seconds, and when the header is missing entirely the Fetch standard's default preflight lifetime is 5 seconds. As of 2026 that default is still the single largest source of unexplained OPTIONS traffic on public APIs: a single-page app firing 12 cross-origin calls with a custom auth header over an 80 ms round trip pays roughly 960 ms of preflight latency on cold start, and pays it again every 5 seconds of idle time.

Setting Access-Control-Max-Age to 7200 collapses that to one preflight per browser, per path, per two hours. It also means a header allowlist change takes up to two hours to propagate to active sessions. That is the trade, and it is not free.

The CDN CORS headers setup that fixes it

Order matters. Apply these in sequence, verifying after each step rather than shipping all five and guessing which one helped.

  1. Decide whether the response is credentialed. If it never carries cookies or Authorization, return a static Access-Control-Allow-Origin of asterisk from the edge and stop. One cache entry, no Vary, no reflection risk. This covers fonts, HLS and DASH segments, sprite maps and public JSON.
  2. If it is credentialed, reflection is mandatory. The wildcard is invalid alongside Access-Control-Allow-Credentials set to true. The origin must echo the exact Origin value and must validate it against an allowlist before echoing.
  3. Add Vary: Origin to every reflected response, including error responses. Then confirm the CDN honours Vary on that header rather than silently ignoring it, which several platforms do for headers outside their default cache key.
  4. Add Origin to the edge cache key explicitly. Do not rely on Vary alone. Normalize first: map the incoming Origin to one of N allowlisted buckets and use the bucket in the key, so an attacker sending 50,000 random Origin values cannot fragment your cache.
  5. Handle OPTIONS at the edge with a full response: Access-Control-Allow-Methods, Access-Control-Allow-Headers covering Range and Authorization where used, Access-Control-Max-Age at 7200, Access-Control-Allow-Credentials where required, and Vary listing Origin, Access-Control-Request-Method and Access-Control-Request-Headers.
  6. Expose what the client reads. Access-Control-Expose-Headers for Content-Range, ETag and any correlation ID; Timing-Allow-Origin if you want real Resource Timing numbers instead of zeros.
Edge configurationCache entries per URLCORS correctnessBest for
Static allow-origin asterisk at the edge, Origin not forwarded1Correct only without credentialsFonts, media segments, public assets
Origin forwarded, response cached without Vary1Broken and potentially leakyNothing
Origin forwarded, Vary: Origin honoured and keyedOne per distinct origin seenCorrectSmall, fixed set of client origins
Origin normalized to allowlist buckets before keyingOne per bucketCorrect, fragmentation boundedMulti-tenant APIs and white-label domains

Normalizing the Origin header into a bounded set of allowlist buckets is the only option that keeps CORS correct without letting cache cardinality grow with the number of client domains.

Trade-offs, failure modes and blind spots

Vary: Origin multiplies cache entries. A media API serving 2,000 objects to 40 tenant subdomains stores up to 80,000 variants instead of 2,000, and each edge pays a cold fill per tenant. On a workload with modest per-tenant traffic that is a measurable hit-ratio loss — plan on several percentage points, and measure it rather than assuming.

Negative caching is the sharpest edge. If the origin rejects an unknown Origin with a 403 and the edge caches 403 responses for 60 seconds without Vary, one bad requester poisons the object for every legitimate client in that region. Either exclude error status codes from caching on CORS-sensitive paths or ensure Vary is applied to them too.

Range requests on video deserve their own note: Range is not a safelisted request header, so byte-range playback triggers preflights, and 206 responses must carry the same allow-origin headers as the 200. Miss Access-Control-Expose-Headers for Content-Range and players silently fall back to full-file downloads.

Observability is the real gap. Browser consoles report a CORS failure without telling you which header was missing, edge logs rarely record response headers by default, and the failing request often never reaches the origin at all. Log Origin, the Vary value and the cache status on every CORS-eligible path before you start debugging. Platforms differ in how much of this is expressible: forwarding Origin, adding it to the cache key and rewriting response headers per path are standard edge header and cache-key configuration capabilities on BlazingCDN and most cost-focused CDNs, while some entry-level plans expose only a single static allow-origin toggle.

Verify this on your own stack

  • Request the same asset twice from your HTTP client with two different Origin request header values. If both responses return the identical Access-Control-Allow-Origin and the second shows a cache hit, you have Cause 2.
  • Check whether Vary: Origin actually survives the edge. If the origin sends it and the edge response does not, the platform is stripping or collapsing it.
  • Count OPTIONS as a share of total origin requests. Above 5 percent means Access-Control-Max-Age is missing or being ignored.
  • Confirm 206 and 4xx responses on the same path carry the same CORS header set as the 200.

FAQ: CORS errors behind a CDN

Why does a CORS error appear only after enabling a CDN?

Because the CDN introduces a shared cache between the browser and the origin. The origin computes Access-Control-Allow-Origin per requester, but the edge stores one response for many requesters. Without Vary: Origin and Origin in the cache key, the first requester's header is replayed to everyone else, producing intermittent failures that disappear when the edge is bypassed.

Should Vary: Origin be set on every CORS response?

Set Vary: Origin on any response whose Access-Control-Allow-Origin is computed by reflecting the request. Omit it when you return a static wildcard, since there is nothing to vary on and the header only fragments caches. Error responses on the same path need identical treatment, otherwise cached 403s become cross-tenant failures.

Does Access-Control-Max-Age reduce CDN and origin load?

It reduces preflight volume, not asset load. Access-Control-Max-Age controls only the browser's preflight cache. Raising it from the 5-second Fetch default to 7200 seconds removes almost all repeat OPTIONS traffic for active sessions. The cost is propagation delay: changes to allowed methods or headers take up to that window to reach clients.

Can Access-Control-Allow-Origin be set to a wildcard with credentials?

No. When Access-Control-Allow-Credentials is true, the browser rejects a wildcard Access-Control-Allow-Origin outright. The server must echo the exact Origin value after validating it against an allowlist, and that response must carry Vary: Origin so shared caches keep the variants separate.

Run this audit this week

Pick your three highest-traffic cross-origin paths and pull 24 hours of edge logs for them. Record four fields: Origin, cache status, response Access-Control-Allow-Origin and status code. If any URL shows more than one distinct allow-origin value against the same cache key, you already have a correctness bug that users are hitting silently. Then measure OPTIONS as a share of origin requests before and after setting Access-Control-Max-Age to 7200. Post the delta to your team channel — the number is usually larger than anyone expects.