CORS gets weird fast once a CDN sits in front of your app.
Without a CDN, you mostly think about browser rules: Origin, preflights, Access-Control-Allow-Origin, maybe credentials. Add a CDN and now you also have cache keys, header normalization, OPTIONS caching, stale variants, and the classic bug where one origin gets cached and leaked to another.
I’ve seen teams debug this for hours because the app server was “correct” but the CDN was serving the wrong cached CORS headers.
Here’s the practical reference guide.
The core problem
CORS decisions are per-origin. CDNs want to cache aggressively across requests.
Those goals conflict unless you design for it.
If your response says:
Access-Control-Allow-Origin: https://app.example.com
then that response is only valid for that origin. If a CDN reuses it for:
Origin: https://admin.example.com
the browser will reject it, or worse, you’ll accidentally allow something you didn’t intend if your edge logic is sloppy.
The fix usually starts with this header:
Vary: Origin
That tells caches the response changes based on the Origin request header.
The three CORS patterns that matter with CDNs
1. Public API, no credentials
This is the easy one.
If the resource is truly public and you do not use cookies or HTTP auth, return:
Access-Control-Allow-Origin: *
That works well with CDNs because every origin gets the same response.
Real example from api.github.com:
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 good fit for a CDN: a single cacheable variant, no per-origin branching.
2. Specific allowed origins, no credentials
This is common for SPAs calling an API.
You reflect or select from an allowlist:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
The CDN must vary on Origin, or bypass cache for these responses.
3. Credentials required
If you need cookies or authenticated cross-origin requests:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
You cannot use * with credentials. Browsers reject that combination.
This is where CDN mistakes hurt the most, because authenticated apps often have stricter origin rules and more dynamic responses.
What the CDN can break
Cached ACAO for the wrong origin
A request from https://app.example.com populates the CDN cache with:
Access-Control-Allow-Origin: https://app.example.com
Later, a request from https://partner.example.com hits the same cached object.
If Origin is not part of the cache key and the response doesn’t send Vary: Origin, you have a broken response.
Preflight responses cached incorrectly
Browsers send preflight OPTIONS requests for non-simple cross-origin requests.
Example:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
If your CDN caches OPTIONS badly, one preflight variant can be reused for another method, header set, or origin.
For preflights, vary on at least:
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Header stripping or normalization
Some CDN setups strip request headers they don’t consider cache-relevant. If Origin never reaches your origin server, your app can’t make the right CORS decision.
Check that the CDN forwards:
OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
Good baseline for origin-specific CORS behind a CDN
Use this for non-public APIs:
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: Authorization, Content-Type, X-Requested-With
Access-Control-Expose-Headers: ETag, Link, Retry-After
Vary: Origin
And for preflight responses:
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: Authorization, Content-Type, X-Requested-With
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Express example
This is a sane pattern for an API behind a CDN.
import express from "express";
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Expose-Headers", "ETag, Link, Retry-After");
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
const reqMethod = req.headers["access-control-request-method"];
const reqHeaders = req.headers["access-control-request-headers"];
if (origin && allowedOrigins.has(origin)) {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
res.setHeader(
"Access-Control-Allow-Headers",
reqHeaders || "Authorization, Content-Type"
);
res.setHeader("Access-Control-Max-Age", "600");
res.setHeader(
"Vary",
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
);
}
return res.status(204).end();
}
next();
});
app.get("/api/data", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
My opinion: don’t blindly reflect any Origin. Use an allowlist. Reflection without validation is the “it worked in staging” version of CORS.
Nginx example
If you terminate behind a CDN and want Nginx to handle CORS:
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers "ETag, Link, Retry-After" always;
add_header Vary "Origin" always;
proxy_pass http://app_backend;
}
}
If your CDN caches these responses, make sure its cache key respects Origin or disable caching for routes with dynamic CORS.
CDN strategy options
Option 1: Use Access-Control-Allow-Origin: * for public assets/APIs
Best for:
- fonts
- public JSON
- public images
- static files
- anonymous APIs
Example:
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=86400
This is the least painful setup.
Option 2: Vary cache by Origin
Best for:
- allowlisted browser apps
- credentialed APIs
- tenant-specific frontends
Requirements:
- forward
Origin - include
Originin cache key or honorVary: Origin - handle preflight variants correctly
Option 3: Don’t cache CORS-sensitive endpoints at the CDN
Sometimes this is the right answer.
If your API is highly dynamic, credentialed, and only lightly benefits from edge caching, it may be safer to skip CDN caching entirely for those routes.
Example:
Cache-Control: private, no-store
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Preflight caching tips
Access-Control-Max-Age controls browser preflight caching, not necessarily CDN caching.
Example:
Access-Control-Max-Age: 600
That means the browser can reuse the preflight result for 10 minutes.
If you also cache OPTIONS at the CDN, be very deliberate. Cache key should include:
- path
OriginAccess-Control-Request-MethodAccess-Control-Request-Headers
If your CDN can’t express that cleanly, I’d rather not cache preflights there.
Debugging checklist
When CORS works locally but fails in production behind a CDN, I check these in order:
- Does the browser request include
Origin? - Does the CDN forward
Originto origin? - Does the response include the expected
Access-Control-Allow-Origin? - If origin-specific, is
Vary: Originpresent? - Are preflight responses varying on method and request headers?
- Is the CDN reusing a cached response across origins?
- Are credentials involved, making
*invalid? - Is some edge function rewriting headers after origin generated them?
Useful curl test:
curl -i https://api.example.com/data \
-H 'Origin: https://app.example.com'
Preflight test:
curl -i -X OPTIONS https://api.example.com/data \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization,content-type'
Then repeat with a different origin and compare the headers.
Exposed headers and CDN-backed APIs
Browsers only expose a limited set of response headers to JavaScript unless you opt in with Access-Control-Expose-Headers.
GitHub’s API is a solid real-world example. It exposes operational headers like:
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 matters for CDN-backed APIs because clients often need:
ETagfor cache validationLinkfor paginationRetry-Afterfor rate limiting- custom request IDs for debugging
If frontend code needs a header, expose it explicitly.
The practical rule
If your CORS policy changes by origin, your cache behavior must also change by origin.
That’s the whole game.
For public resources, use Access-Control-Allow-Origin: * and keep it simple. For credentialed or allowlisted APIs, send Vary: Origin, validate origins strictly, and make sure your CDN cache key matches reality. If it doesn’t, CORS will fail in ways that look random and waste your afternoon.