CORS gets weird at the edge.
On a normal app server, you usually control one thing: the response. At the edge, you control the response, the cache key, sometimes the request headers, and sometimes a chain of proxies you barely remember setting up six months ago. That’s where small CORS mistakes turn into “works in curl, fails in browser” bugs.
This guide is the version I wish I had the first few times I debugged CORS on a CDN or edge worker.
What changes when CORS moves to the edge
At the edge, CORS is usually handled in one of three places:
- Directly in an edge function or worker
- At the CDN layer with header rules
- As a proxy in front of an origin you don’t control
That last one is the most common reason people touch CORS at the edge. You want your frontend at https://app.example.com to call some API, but the API either has no CORS support or the wrong CORS policy. So you proxy it through the edge and add the right headers there.
That works, but now you own:
OPTIONSpreflight handlingAccess-Control-Allow-*headersVary: Origin- cache correctness
- credential rules
- exposed response headers
If you get Vary wrong, one origin can poison another origin’s cached CORS response. I’ve seen this happen in production. It’s ugly.
The minimum CORS response at the edge
For a simple cross-origin GET without credentials, this is enough:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
If you truly want public access from any origin:
Access-Control-Allow-Origin: *
That’s what api.github.com does:
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 second header matters more than people think. Browsers hide most response headers from JavaScript unless you explicitly expose them. If your frontend needs pagination from Link or caching info from ETag, you need Access-Control-Expose-Headers.
Copy-paste edge worker: dynamic allowlist
This pattern is the one I use most: allow a small set of origins, answer preflights, and add Vary: Origin.
Cloudflare Workers
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
function buildCorsHeaders(origin) {
const headers = new Headers();
if (origin && ALLOWED_ORIGINS.has(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Vary", "Origin");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
headers.set("Access-Control-Max-Age", "86400");
headers.set("Access-Control-Expose-Headers", "ETag, Link, X-Request-Id");
}
return headers;
}
export default {
async fetch(request) {
const origin = request.headers.get("Origin");
const corsHeaders = buildCorsHeaders(origin);
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}
const upstream = await fetch("https://api.example.internal/data", {
method: request.method,
headers: request.headers,
body: request.method === "GET" || request.method === "HEAD"
? undefined
: request.body,
});
const response = new Response(upstream.body, upstream);
corsHeaders.forEach((value, key) => response.headers.set(key, value));
return response;
},
};
A few opinions here:
- I prefer an explicit allowlist over regex unless there’s a real need.
- I always set
Vary: Originwhen mirroring the request origin. - I return
204for preflight. Clean and boring.
Vercel Edge Middleware / Edge Functions pattern
Same idea, different API shape:
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
function applyCors(req, res) {
const origin = req.headers.get("origin");
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.headers.set("Access-Control-Allow-Origin", origin);
res.headers.set("Vary", "Origin");
res.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.headers.set("Access-Control-Max-Age", "86400");
res.headers.set("Access-Control-Expose-Headers", "ETag, Link, X-Request-Id");
}
return res;
}
export default async function handler(req) {
if (req.method === "OPTIONS") {
return applyCors(req, new Response(null, { status: 204 }));
}
const upstream = await fetch("https://api.example.internal/data", {
method: req.method,
headers: req.headers,
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
});
return applyCors(req, new Response(upstream.body, upstream));
}
Credentials at the edge: where people break things
If you need cookies or Authorization in a browser CORS request, * is off the table.
You must return the exact origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
And your frontend must opt in too:
fetch("https://edge.example.com/api/user", {
credentials: "include",
});
If you send this, the browser will reject it:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combo is invalid for credentialed browser requests.
Also, don’t blindly reflect any incoming Origin. That turns your edge into a universal cross-origin bridge. If you are proxying authenticated APIs, that’s a terrible idea.
Preflight handling that won’t surprise you later
Browsers send preflight requests for non-simple methods or headers. At the edge, answer them directly instead of forwarding to origin unless origin really needs to decide.
A good preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Vary: Origin
If you want to be stricter, you can validate the requested method and headers:
function handlePreflight(request) {
const origin = request.headers.get("Origin");
const reqMethod = request.headers.get("Access-Control-Request-Method");
const reqHeaders = request.headers.get("Access-Control-Request-Headers") || "";
if (!ALLOWED_ORIGINS.has(origin)) {
return new Response(null, { status: 403 });
}
const allowedMethods = new Set(["GET", "POST", "PUT", "DELETE"]);
if (!allowedMethods.has(reqMethod)) {
return new Response(null, { status: 405 });
}
const allowedHeaders = new Set(["content-type", "authorization"]);
const requested = reqHeaders
.split(",")
.map(h => h.trim().toLowerCase())
.filter(Boolean);
for (const h of requested) {
if (!allowedHeaders.has(h)) {
return new Response(null, { status: 400 });
}
}
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
},
});
}
That’s stricter than most examples online, which is why I like it.
Caching and Vary: Origin
This is the edge-specific footgun.
If your worker returns:
Access-Control-Allow-Origin: https://app.example.com
for one request, and later serves the same cached object to:
Origin: https://evil.example
without varying by origin, the cached headers can be wrong.
Use:
Vary: Origin
If your preflight response depends on requested headers or methods, consider varying there too:
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
That’s especially useful if you cache preflight responses at the CDN layer.
Exposing headers from proxied APIs
This is common at the edge because you often proxy third-party APIs and want frontend code to access upstream metadata.
For example, if you proxy GitHub-style pagination or rate limit headers:
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Then your frontend can actually read them:
const res = await fetch("https://edge.example.com/github/issues");
const link = res.headers.get("Link");
const etag = res.headers.get("ETag");
const remaining = res.headers.get("X-RateLimit-Remaining");
console.log({ link, etag, remaining });
Without Access-Control-Expose-Headers, those reads usually return null even though DevTools shows the headers.
Proxying a third-party API through the edge
This is the standard “fix CORS with a worker” pattern:
export default {
async fetch(request) {
const origin = request.headers.get("Origin");
const allowed = origin === "https://app.example.com";
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: allowed ? {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
} : {},
});
}
const apiRes = await fetch("https://api.github.com/repos/octocat/Hello-World", {
headers: {
"Accept": "application/vnd.github+json",
"User-Agent": "edge-proxy-demo",
},
});
const res = new Response(apiRes.body, apiRes);
if (allowed) {
res.headers.set("Access-Control-Allow-Origin", origin);
res.headers.set("Access-Control-Expose-Headers",
"ETag, Link, Location, Retry-After, 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"
);
res.headers.set("Vary", "Origin");
}
return res;
},
};
That expose list mirrors the real api.github.com behavior closely enough to be useful in browser code.
Debugging checklist
When CORS fails at the edge, I check these in order:
- Did the request even reach the edge?
- Is
OPTIONShandled directly? - Is the response missing
Access-Control-Allow-Origin? - Am I using credentials with
*? - Did I forget
Vary: Origin? - Am I trying to read a header that isn’t exposed?
- Is the CDN caching a response with the wrong CORS headers?
- Did middleware and origin both set conflicting CORS headers?
That last one is sneaky. Duplicate CORS headers from origin and edge can create weird browser behavior. I usually strip origin CORS headers at the edge and set one clean policy there.
Good defaults
If you want a boring, production-safe baseline for edge CORS:
- allow only known origins
- reflect exact origin, never wildcard with credentials
- answer
OPTIONSat the edge - set
Vary: Origin - expose only the headers frontend code actually needs
- keep preflight
Max-Agereasonable - centralize CORS in one layer
And if you’re also hardening the rest of your response headers, check official platform docs for edge header controls. For broader header policy work beyond CORS, https://csp-guide.com is a handy companion.