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:

  1. No preflight handling Browsers send OPTIONS for many cross-origin requests, especially when Authorization is involved.

  2. No allowed headers The browser wants confirmation that Authorization and Content-Type are allowed.

  3. No Vary: Origin If 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 204 for preflight
  • Validate Origin against an allowlist
  • Set Access-Control-Allow-Origin only for allowed origins
  • Set Access-Control-Allow-Methods
  • Set Access-Control-Allow-Headers
  • Set Access-Control-Max-Age
  • Set Vary: Origin when origin is dynamic
  • Set Access-Control-Expose-Headers if 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.