A lot of CORS bugs don’t start in the app. They start at the edge.

I’ve seen teams spend days debugging “random” frontend failures only to find the real issue sitting in a CDN rule added six months earlier by someone trying to improve cache hit ratio. The app was fine. The browser was fine. The CDN was serving the wrong CORS headers to the wrong origin.

That’s the messy reality of global CDN configurations: once responses are cached and reused across regions, CORS mistakes get amplified fast.

Here’s a real-world style case study based on a pattern I’ve seen more than once.

The setup

A company had a web app served from:

https://app.example.com

Their API sat behind a global CDN at:

https://api.example.com

They also had:

  • a staging frontend at https://staging-app.example.com
  • a partner dashboard at https://partners.example.com
  • some static assets and JSON config files also served through the same CDN

The goal looked simple:

  • allow app.example.com
  • allow staging-app.example.com
  • allow partners.example.com
  • support authenticated API requests with cookies
  • cache public GET responses aggressively at the CDN
  • avoid origin server load across regions

On paper, easy. In production, not so much.

The original configuration

Their CDN rule was trying to be helpful. It injected CORS headers at the edge for every /v1/* response:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

And preflight responses were cached globally for an hour:

Cache-Control: public, max-age=3600

That setup had three serious problems.

1. Wildcard plus credentials

This is the classic broken combo:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject it for credentialed requests. If the frontend uses fetch(..., { credentials: "include" }), the response is blocked.

So users saw errors like:

fetch("https://api.example.com/v1/me", {
  credentials: "include"
})

Browser result:

Access to fetch at 'https://api.example.com/v1/me' from origin 'https://app.example.com'
has been blocked by CORS policy:
The value of the 'Access-Control-Allow-Origin' header in the response
must not be '*' when the request's credentials mode is 'include'.

2. The CDN cached one origin’s CORS result and served it to another

Someone partially “fixed” the wildcard issue by changing the edge logic to reflect the incoming Origin header:

// pseudo edge logic
response.headers["Access-Control-Allow-Origin"] = request.headers["Origin"];
response.headers["Access-Control-Allow-Credentials"] = "true";

That sounds reasonable, but they forgot the cache key and Vary.

The CDN cached this response:

Access-Control-Allow-Origin: https://app.example.com

Then served that same cached object to requests from:

https://partners.example.com

Now the browser rejected perfectly valid partner requests because the response carried the wrong allowed origin.

3. Preflight caching was too broad

The CDN cached OPTIONS responses without varying on:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers

That meant a preflight approved for one origin and header set could get replayed to another request that should have been denied.

That’s not just flaky. That’s dangerous.

The symptoms

The frontend team reported:

  • requests worked in one region and failed in another
  • staging worked after a hard refresh but failed later
  • partner users saw intermittent auth failures
  • browser devtools showed CORS errors, but curl tests looked fine

That last one is a trap. curl doesn’t enforce browser CORS rules. A response can look perfectly healthy in curl and still be unusable in the browser.

Before: what the traffic actually looked like

A cached API response looked like this:

HTTP/2 200
Content-Type: application/json
Cache-Control: public, s-maxage=600
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

But there was no:

Vary: Origin

So the CDN treated the object as the same cache entry for every caller.

Preflight responses were also missing the right variance:

HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Cache-Control: public, max-age=3600

Again, no Vary.

The fix

We changed the architecture in two ways.

First: split public and credentialed endpoints

This is the biggest practical improvement.

Public endpoints were moved into a policy that allowed wildcard origin with no credentials:

Access-Control-Allow-Origin: *

That works well for truly public resources, and it’s exactly how some major APIs expose read-only metadata. For example, api.github.com returns:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s a clean pattern for public API responses: broad readability, explicit exposed headers.

Credentialed endpoints got a separate path and separate cache behavior:

/v1/public/*
/v1/private/*

Second: make CORS part of the cache strategy

If you reflect origins, your cache has to know that.

For private endpoints, we configured:

Vary: Origin

And for preflights:

Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

Without that, the CDN will happily poison your own CORS behavior.

After: edge logic that actually works

Here’s a practical edge function example.

const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "https://staging-app.example.com",
  "https://partners.example.com"
]);

function applyCors(req, res) {
  const origin = req.headers.origin;

  if (!origin) return res;

  if (req.url.startsWith("/v1/public/")) {
    res.headers["Access-Control-Allow-Origin"] = "*";
    res.headers["Access-Control-Expose-Headers"] =
      "ETag, Link, Location, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining";
    return res;
  }

  if (req.url.startsWith("/v1/private/") && ALLOWED_ORIGINS.has(origin)) {
    res.headers["Access-Control-Allow-Origin"] = origin;
    res.headers["Access-Control-Allow-Credentials"] = "true";
    res.headers["Access-Control-Expose-Headers"] =
      "ETag, Link, Location, Retry-After";
    res.headers["Vary"] = appendVary(res.headers["Vary"], "Origin");
  }

  return res;
}

function handlePreflight(req) {
  const origin = req.headers.origin;
  const reqMethod = req.headers["access-control-request-method"];
  const reqHeaders = req.headers["access-control-request-headers"] || "";

  if (!ALLOWED_ORIGINS.has(origin)) {
    return {
      status: 403,
      headers: {
        "Content-Type": "text/plain"
      },
      body: "CORS origin denied"
    };
  }

  return {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Credentials": "true",
      "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": reqHeaders,
      "Access-Control-Max-Age": "600",
      "Vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
    }
  };
}

function appendVary(current, value) {
  if (!current) return value;
  const parts = new Set(current.split(",").map(v => v.trim()));
  parts.add(value);
  return Array.from(parts).join(", ");
}

This does a few things right:

  • public and private paths are treated differently
  • wildcard is only used where credentials are not involved
  • reflected origins are allowlisted
  • Vary is set correctly
  • preflight behavior varies on the fields that actually matter

After: the response headers

For a public resource:

HTTP/2 200
Cache-Control: public, s-maxage=3600
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining

For a credentialed private resource requested by https://app.example.com:

HTTP/2 200
Cache-Control: private, no-store
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
Vary: Origin

For preflight:

HTTP/2 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Client-Version
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

The result

Once the CDN behavior matched the CORS model, the “random” failures disappeared.

What changed operationally:

  • browser CORS errors dropped to near zero
  • cache hit ratio stayed high for public endpoints
  • private endpoints stopped leaking the wrong allow-origin header across tenants
  • partner integrations became predictable across regions
  • debugging got easier because responses were deterministic

That last part matters more than people admit. A bad CORS setup behind a CDN is hard to reason about because the browser, edge cache, and origin all affect the final result. If the rules are ambiguous, your incident response gets ugly.

What I’d do every time now

If I’m designing CORS for a global CDN today, I stick to a few hard rules:

1. Separate public and credentialed traffic

If an endpoint needs cookies or auth tied to browser credentials, don’t share its CORS behavior with public cacheable JSON.

2. Never reflect Origin without Vary: Origin

This is the one that burns teams the most.

3. Preflight responses need their own cache rules

If your CDN caches OPTIONS, vary on:

Origin, Access-Control-Request-Method, Access-Control-Request-Headers

4. Expose only the headers frontend code actually needs

If your frontend reads pagination, rate limit, or entity tag headers, expose them explicitly.

The GitHub API is a good real-world example of this style:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset

That’s not accidental. It’s how you make cross-origin API responses usable without exposing everything.

5. Test in a browser, not just with curl

Curl is fine for inspecting headers. It won’t tell you whether the browser will actually allow your frontend to read the response.

One last gotcha

Some teams try to centralize all security headers in the CDN, including CORS, CSP, and others. That can work, but CORS is more request-dependent than most headers. A static edge rule is often too blunt.

If you’re also managing broader header policy, keep CORS separate from generic security-header injection. Different problem, different shape. If you need help with non-CORS headers like CSP, https://csp-guide.com is a solid reference.

For CORS specifically, the safest global CDN configuration is usually the least clever one:

  • wildcard for truly public resources
  • explicit allowlist for credentialed ones
  • Vary aligned with cache behavior
  • separate caching policy for preflight

That’s the version that survives production.