CORS with Cloudflare Zero Trust tends to fail in ways that look random until you understand who is actually answering the browser.

That’s the first mistake: treating CORS like an app-only problem when Cloudflare is sitting in front of your app, enforcing Access policies, redirecting unauthenticated users, and sometimes answering OPTIONS before your origin ever sees it.

If you’ve ever said “but my API sends Access-Control-Allow-Origin just fine” while the browser still throws a CORS error, this is probably why.

Mistake #1: Protecting an API with an interactive Access login flow

This one bites teams constantly.

Cloudflare Zero Trust Access is great for browser apps where a human can log in. It’s a bad fit for cross-origin API calls from frontend code if the browser gets redirected to a login page during a fetch.

A browser CORS request expects CORS headers on the actual API response. If Cloudflare responds with a 302 to an Access login page, or serves an HTML login form, your frontend sees a CORS failure. The browser doesn’t care that your origin would have answered correctly. It never got there.

Typical broken flow:

fetch("https://api.internal.example.com/data", {
  credentials: "include"
})

What actually happens:

  1. Browser sends request
  2. Cloudflare Access sees no valid session
  3. Cloudflare returns redirect or login HTML
  4. Browser checks CORS on that response
  5. No valid CORS headers for your frontend origin
  6. You get a useless “blocked by CORS policy” error

Fix

Use the right auth model for APIs behind Zero Trust:

  • service tokens for machine-to-machine access
  • JWT validation at the origin
  • Access policies designed for non-interactive requests
  • same-origin architecture when possible

If your frontend app and API are cross-origin, you need Cloudflare to return proper CORS headers even on auth failures, or better, avoid browser-mediated cross-origin auth entirely.

A pattern I like is putting the frontend and backend behind the same site and routing API calls through the same origin:

fetch("/api/data", {
  credentials: "include"
})

That dodges a lot of CORS complexity. Same-origin beats clever CORS config every time.

Mistake #2: Forgetting preflight requests are unauthenticated by design

Browsers send preflight OPTIONS requests without your app’s custom auth headers.

That means if Cloudflare Access or your origin expects something like this:

Authorization: Bearer abc123

the preflight will still arrive without it.

Then your edge or origin rejects the OPTIONS, and the real request never happens.

A browser might send:

OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type

If Cloudflare responds with a block page, redirect, or 403 without the right CORS headers, the browser stops there.

Fix

Make OPTIONS succeed cleanly before auth logic kicks in.

Your API stack or edge config should allow preflight requests through and answer them with explicit CORS policy:

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: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

If you’re using a Worker in front of the origin, you can short-circuit preflights:

export default {
  async fetch(request, env) {
    const origin = request.headers.get("Origin");
    const allowedOrigin = "https://app.example.com";

    if (request.method === "OPTIONS") {
      if (origin === allowedOrigin) {
        return new Response(null, {
          status: 204,
          headers: {
            "Access-Control-Allow-Origin": allowedOrigin,
            "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
            "Access-Control-Allow-Headers": "Authorization, Content-Type",
            "Access-Control-Allow-Credentials": "true",
            "Access-Control-Max-Age": "600",
            "Vary": "Origin"
          }
        });
      }

      return new Response(null, { status: 403 });
    }

    const response = await fetch(request);
    const newHeaders = new Headers(response.headers);

    if (origin === allowedOrigin) {
      newHeaders.set("Access-Control-Allow-Origin", allowedOrigin);
      newHeaders.set("Access-Control-Allow-Credentials", "true");
      newHeaders.set("Vary", "Origin");
    }

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: newHeaders
    });
  }
}

Mistake #3: Using Access-Control-Allow-Origin: * with credentials

This is the classic CORS footgun, and Zero Trust setups make it worse because authenticated APIs usually need cookies or auth state.

If your frontend does this:

fetch("https://api.example.com/me", {
  credentials: "include"
})

then this response is invalid:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject it.

You can absolutely use wildcard origins for public APIs. GitHub does. Real headers from api.github.com include:

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 works because public API access and credentialed browser sessions are different problems.

Fix

For credentialed requests, echo a specific allowed origin:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

If you support multiple frontend origins, whitelist them explicitly:

const allowedOrigins = new Set([
  "https://app.example.com",
  "https://admin.example.com"
]);

const origin = request.headers.get("Origin");
if (allowedOrigins.has(origin)) {
  headers.set("Access-Control-Allow-Origin", origin);
  headers.set("Access-Control-Allow-Credentials", "true");
  headers.set("Vary", "Origin");
}

Not regex soup. Not blind reflection. A real allowlist.

Mistake #4: Returning CORS headers only on 200 responses

I see this one a lot in Express, Nginx, and Workers.

Teams add CORS headers in the happy path, then forget error responses generated by:

  • Cloudflare Access
  • WAF rules
  • rate limits
  • bot protection
  • origin auth middleware
  • application exceptions

The browser still enforces CORS on 401, 403, 404, and 500 responses. If those responses don’t carry the right headers, your frontend gets a generic CORS error instead of the real status code.

That makes debugging miserable.

Fix

Attach CORS headers to every relevant response, especially failures.

In Express:

app.use((req, res, next) => {
  const allowedOrigins = new Set(["https://app.example.com"]);
  const origin = req.headers.origin;

  if (allowedOrigins.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    res.setHeader("Vary", "Origin");
  }

  if (req.method === "OPTIONS") {
    return res.status(204).end();
  }

  next();
});

Then make sure proxies and edge layers don’t strip or replace those headers.

If you want to verify what actually comes back through Cloudflare, not just what your app thinks it sent, check the live headers with HeaderTest. I use tools like that when I suspect the edge is rewriting something.

Mistake #5: Not exposing headers your frontend needs

This one is less obvious because the request succeeds, but your code can’t read certain response headers.

Browsers only expose a limited set of “simple response headers” to JavaScript unless you list more with Access-Control-Expose-Headers.

GitHub is a good real-world example here. Their API exposes useful headers like:

  • ETag
  • Link
  • Location
  • Retry-After
  • X-RateLimit-Remaining
  • X-RateLimit-Reset

That’s what this header is for:

Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-RateLimit-Remaining

Fix

If your frontend reads pagination, rate-limit, or tracing headers, expose them explicitly.

headers.set(
  "Access-Control-Expose-Headers",
  "ETag, Link, Location, Retry-After, X-RateLimit-Remaining, X-Request-Id"
);

Without that, this code fails silently:

const res = await fetch("https://api.example.com/items");
console.log(res.headers.get("X-RateLimit-Remaining")); // null unless exposed

Mistake #6: Caching CORS responses without Vary: Origin

This gets nasty behind CDNs.

If Cloudflare or another cache stores a response for one origin and reuses it for another, you can leak the wrong Access-Control-Allow-Origin value or break valid requests.

Fix

If your response varies by origin, say so:

Vary: Origin

This matters any time you dynamically reflect an allowed origin instead of using *.

And if you’re doing cache rules at the edge, double-check they aren’t flattening your CORS behavior into one cached response for everyone.

Mistake #7: Blindly reflecting requested headers and origins

I get why people do this. It “fixes” CORS in five minutes.

It also turns policy into theater.

Bad example:

headers.set("Access-Control-Allow-Origin", request.headers.get("Origin"));
headers.set(
  "Access-Control-Allow-Headers",
  request.headers.get("Access-Control-Request-Headers") || "*"
);

That means any site can ask, and you’ll probably say yes.

With Zero Trust in the mix, developers sometimes assume “Cloudflare is handling security anyway.” That’s not a reason to make CORS meaningless. CORS is still part of your browser-side trust boundary.

Fix

Validate origins and keep allowed headers narrow:

const allowedOrigins = new Set(["https://app.example.com"]);
const allowedHeaders = ["Authorization", "Content-Type"];

if (allowedOrigins.has(origin)) {
  headers.set("Access-Control-Allow-Origin", origin);
  headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
}

And if you’re also working through broader header hardening, CSP, and related browser protections, keep that separate from your CORS logic. Different job, different policy. If you need a refresher on that side, csp-guide.com is useful.

What I’d do in practice

For Cloudflare Zero Trust and browser-based apps, I’d keep the rules simple:

  • prefer same-origin API calls
  • let OPTIONS bypass auth challenges
  • never use * with credentials
  • return CORS headers on error responses too
  • expose only the headers the frontend actually needs
  • set Vary: Origin
  • use explicit allowlists, not reflection hacks

Most “CORS problems” in Zero Trust setups are really routing and auth-flow problems wearing a CORS mask. Once you figure out whether the browser is talking to your origin, a Cloudflare Access page, or some edge-generated error, the fix gets a lot more obvious.