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:
OriginAccess-Control-Request-MethodAccess-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
Varyis 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
Varyaligned with cache behavior- separate caching policy for preflight
That’s the version that survives production.