I’ve seen the same Netlify Edge Functions CORS bug more times than I can count: the function works perfectly in curl, looks fine in local testing, then the browser blows up with a vague CORS error and the frontend team starts blaming fetch.
Usually the problem is simple. The Edge Function returns JSON, but forgets the preflight request, forgets Vary: Origin, or hardcodes * while also trying to send cookies. That combo is enough to turn a clean deployment into an afternoon of browser-tab archaeology.
Here’s a real-world style case study based on a setup I’ve had to fix before.
The setup
A team had a frontend app on one domain:
https://app.example.com
And a Netlify site exposing an Edge Function on another:
https://api-example.netlify.app/.netlify/edge-functions/profile
The frontend needed to call the edge endpoint with:
Authorization: Bearer ...Content-Type: application/json
That means the browser will send a preflight OPTIONS request before the actual GET or POST.
The team tested with curl:
curl -i https://api-example.netlify.app/.netlify/edge-functions/profile
It returned 200 OK, so they shipped it.
Then the browser did this:
fetch("https://api-example.netlify.app/.netlify/edge-functions/profile", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
}
});
And the browser blocked it.
The broken version
This was roughly the original Edge Function:
export default async (request, context) => {
const data = {
user: "alice",
role: "admin"
};
return new Response(JSON.stringify(data), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
};
Looks harmless. It even has Access-Control-Allow-Origin. But it’s still broken for a real browser flow.
What’s wrong here
Three problems:
-
No preflight handling Browsers send
OPTIONSfor many cross-origin requests, especially whenAuthorizationis involved. -
No allowed headers The browser wants confirmation that
AuthorizationandContent-Typeare allowed. -
No
Vary: OriginIf you later switch from*to specific origins, caches can serve the wrong response across origins.
And if they ever wanted cookies or authenticated browser credentials, * would be dead wrong anyway.
What the browser was effectively asking
Before the real request, the browser sent something like:
OPTIONS /.netlify/edge-functions/profile HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization,content-type
The server needed to answer with something like:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
Instead, it answered like a normal request or didn’t handle OPTIONS properly at all.
The symptom in production
The frontend saw errors like:
Access to fetch at 'https://api-example.netlify.app/.netlify/edge-functions/profile'
from origin 'https://app.example.com' has been blocked by CORS policy:
Request header field authorization is not allowed by Access-Control-Allow-Headers
in preflight response.
That error is annoyingly specific once you know what to look for, but teams still lose time because they inspect the actual GET response instead of the OPTIONS preflight.
If you want to sanity-check exactly what headers are coming back from an endpoint, I like using HeaderTest because it makes header inspection less painful than digging through browser tooling for every request.
The fixed version
Here’s the safer Netlify Edge Function pattern.
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging-app.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, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
headers.set("Access-Control-Max-Age", "86400");
return headers;
}
export default async (request, context) => {
const origin = request.headers.get("Origin");
const corsHeaders = buildCorsHeaders(origin);
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders
});
}
if (!origin || !ALLOWED_ORIGINS.has(origin)) {
return new Response(JSON.stringify({ error: "Origin not allowed" }), {
status: 403,
headers: {
"Content-Type": "application/json"
}
});
}
const data = {
user: "alice",
role: "admin"
};
const headers = new Headers(corsHeaders);
headers.set("Content-Type", "application/json");
return new Response(JSON.stringify(data), {
status: 200,
headers
});
};
This version fixes the actual browser flow, not just the happy path in curl.
Why this version works
1. It handles preflight explicitly
If the request method is OPTIONS, return early with a 204. Don’t run auth logic, don’t hit upstream APIs, don’t do unnecessary work.
That alone removes a bunch of weird failures.
2. It reflects only trusted origins
A lot of examples online do this:
headers.set("Access-Control-Allow-Origin", request.headers.get("Origin"));
That’s lazy and risky. You’ve basically turned CORS into “everyone gets in” with extra steps.
Use an allowlist.
3. It includes Access-Control-Allow-Headers
If the browser asks to send Authorization, your response has to allow it. Same story for Content-Type and any custom header your frontend uses.
4. It sets Vary: Origin
This one gets skipped constantly. If your edge response is cacheable and different origins get different CORS values, Vary: Origin tells caches not to mix them up.
I’m opinionated about this: if you dynamically set Access-Control-Allow-Origin and don’t send Vary: Origin, you’re planting a bug for later.
Before and after behavior
Before
Frontend request:
await fetch("https://api-example.netlify.app/.netlify/edge-functions/profile", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
}
});
Result:
- curl works
- browser preflight fails
- app throws CORS error
- team wastes time checking auth tokens
After
Same frontend request:
const res = await fetch("https://api-example.netlify.app/.netlify/edge-functions/profile", {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
}
});
const json = await res.json();
console.log(json);
Result:
- preflight returns
204 - browser proceeds with actual request
- JSON response is readable from the frontend
- behavior is predictable across environments
A useful detail people forget: exposed headers
Sometimes the request succeeds, but the frontend still can’t read certain response headers in JavaScript.
That’s where Access-Control-Expose-Headers matters.
GitHub’s API is a good real-world example of this done properly. 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, Warning
That’s not decorative. It means frontend code can actually read those headers.
If your Netlify Edge Function returns rate limit info or pagination links, expose them explicitly:
headers.set("Access-Control-Expose-Headers", "ETag, Link, X-RateLimit-Remaining");
Then your frontend can do:
const res = await fetch(url);
console.log(res.headers.get("ETag"));
console.log(res.headers.get("X-RateLimit-Remaining"));
Without Access-Control-Expose-Headers, those values may exist on the wire but still be inaccessible to browser JavaScript.
When * is okay, and when it’s not
Access-Control-Allow-Origin: * is fine for truly public, unauthenticated resources.
Think:
- public JSON feeds
- open metadata endpoints
- documentation APIs with no user context
That’s roughly how GitHub can get away with * for many public API responses.
But once you’re dealing with:
- cookies
- session auth
- private user data
- tenant-specific responses
Don’t use *. Use explicit origins.
And if you’re adding broader security headers around your edge responses, not just CORS, I’d point people to csp-guide.com for the CSP side of the house. CORS controls who can read responses cross-origin. CSP controls what the browser is allowed to load and execute. Different tools, different failures.
My default Netlify Edge Function CORS checklist
When I wire up CORS at the edge, I check these every time:
- Handle
OPTIONS - Return
204for preflight - Validate
Originagainst an allowlist - Set
Access-Control-Allow-Originonly for allowed origins - Set
Access-Control-Allow-Methods - Set
Access-Control-Allow-Headers - Set
Access-Control-Max-Age - Set
Vary: Originwhen origin is dynamic - Set
Access-Control-Expose-Headersif the frontend needs response metadata
That’s the difference between “it works on my machine” CORS and production-grade CORS.
Netlify Edge Functions make this stuff fast and convenient, but they don’t magically remove browser rules. The browser still wants a proper preflight response, still enforces header access rules, and still punishes sloppy configs.
If your Edge Function is returning data but the frontend can’t read it, don’t start with auth. Start with the preflight. That’s where the bug usually is.