Cloudflare Access is great at putting identity in front of internal apps and APIs. CORS is great at making frontend apps talk to APIs across origins. Put them together and you get a setup that works well — until it really doesn’t.
I’ve seen teams assume Cloudflare Access will “just handle” cross-origin browser requests. It won’t. Access solves authentication and authorization at the edge. CORS is still your job, and the browser is still brutally strict about it.
If you’re deciding how to handle CORS for an API behind Cloudflare Access, there are a few common patterns. Some are clean. Some are painful. A couple look easy at first and turn into debugging marathons.
The core problem
Cloudflare Access sits between the browser and your origin. For browser-based apps, that changes the request flow:
- Your frontend at
https://app.example.comcallshttps://api.example.com - The browser sends a CORS preflight
OPTIONSrequest if needed - Cloudflare Access may challenge or validate the request before it ever reaches origin
- The browser expects valid CORS headers on both preflight and actual responses
That last part is where people get burned. If the preflight gets redirected to a login flow, challenged, or answered without the right headers, the browser blocks the request before your application logic even runs.
So the comparison isn’t really “does Cloudflare Access support CORS?” It’s “where should CORS be handled when Access is in the middle?”
Option 1: Handle CORS at the origin behind Cloudflare Access
This is the default mental model: your API server returns the CORS headers, and Access protects the route.
Pros
- Simple application ownership: the API team controls CORS where the API lives
- Framework support is mature: Express, FastAPI, Go middleware, Rails, Laravel — all have decent CORS support
- Easy per-route logic: you can vary allowed origins, methods, and headers based on endpoint or tenant
- Works well for non-browser clients too: your API behavior stays consistent regardless of Cloudflare config
Cons
- Preflight can fail before reaching origin: if Cloudflare Access intercepts
OPTIONS, your beautiful origin CORS config never matters - Harder to debug: browser console says “CORS error,” but the real issue is Access auth or redirect behavior
- Requires Access-aware design: you may need to bypass or specially handle unauthenticated preflight requests
This option is fine if you can guarantee OPTIONS requests get through cleanly or are correctly answered before auth blocks them.
Here’s a typical Express setup:
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = [
"https://app.example.com",
"https://admin.example.com",
];
app.use(cors({
origin(origin, callback) {
if (!origin) return callback(null, true); // non-browser or same-origin
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "CF-Access-Jwt-Assertion"],
exposedHeaders: ["X-Request-Id", "ETag"]
}));
app.options("*", cors());
app.get("/data", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
The catch: this only helps if the preflight reaches Express.
Option 2: Handle CORS at Cloudflare’s edge
This is usually the cleaner setup when Access is involved. You respond to preflight at the edge and return consistent CORS headers before origin auth gets messy.
You can do this with Cloudflare Workers or other edge logic in front of the protected origin.
Pros
- Preflight never hits auth weirdness: the edge can answer
OPTIONSimmediately - Faster: no origin trip for preflight
- Centralized policy: one place for CORS across multiple services
- Easier browser behavior: fewer redirects, fewer blocked requests, less guesswork
Cons
- Two layers of config: CORS at edge, auth at Access, app logic at origin
- Can drift from application reality: edge says
PUTis allowed, origin actually rejects it - More infrastructure complexity: not every team wants Workers in the request path
- Dynamic tenant origin rules can get awkward: app-level logic is often better at nuanced allowlists
If I’m protecting an internal API with Access and serving a browser frontend from a different origin, this is the pattern I usually prefer.
A Worker-based preflight handler looks like this:
export default {
async fetch(request) {
const origin = request.headers.get("Origin");
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
const corsHeaders = (() => {
if (!origin || !allowedOrigins.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, CF-Access-Jwt-Assertion",
"Access-Control-Max-Age": "86400",
"Vary": "Origin",
};
})();
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}
const response = await fetch(request);
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
};
This setup is opinionated in the right way: preflight is cheap, explicit, and detached from login flow complexity.
Option 3: Bypass Access for preflight only
This is the compromise pattern. Keep the API protected by Access, but allow unauthenticated OPTIONS requests through so origin can answer CORS.
Pros
- Preserves origin-owned CORS logic
- Avoids Access breaking preflight
- Less edge code than a Worker-based setup
Cons
- Policy split across products: auth bypass in Access, CORS at origin
- Easy to misconfigure: teams accidentally bypass more than
OPTIONS - Still requires careful origin handling
- Can surprise security reviewers: “Why are unauthenticated requests allowed?”
This is acceptable when your security team understands that CORS preflight is not data access. An OPTIONS request returning allowed methods and headers is not the same as exposing protected API data.
Still, I don’t love this pattern unless the platform team has strong guardrails. One sloppy rule and you’ve got a much bigger problem than CORS.
Option 4: Avoid cross-origin entirely
Sometimes the best CORS fix is no CORS.
If your frontend and API can share the same origin — or sit behind a reverse proxy under the same site — you can skip most of this mess. For example:
- Frontend:
https://app.example.com - API proxied as:
https://app.example.com/api
Pros
- No browser CORS dance
- Much simpler auth story
- Fewer moving parts
- Usually easier local-to-prod parity
Cons
- Not always possible: org boundaries, legacy DNS, separate stacks
- Proxying can complicate routing
- Can hide service boundaries that matter operationally
If you can do same-origin cleanly, do it. CORS is one of those specs that is manageable but never pleasant.
A useful reality check from a public API
A lot of developers only think about Access-Control-Allow-Origin, but production APIs often expose much more.
Here’s a 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, Warning
That access-control-expose-headers line matters. If your frontend needs rate limit data, pagination links, request IDs, deprecation notices, or retry hints, you must explicitly expose those headers. Otherwise the browser hides them from JavaScript even though they exist on the response.
That’s especially relevant for APIs behind Cloudflare Access because teams often focus so hard on getting requests through that they forget response usability.
My take: best option by use case
Best for internal APIs with browser frontends
Edge-handled CORS plus Cloudflare Access for auth
This is the least fragile setup. Preflight is answered cleanly, Access still protects actual requests, and your frontend behaves predictably.
Best for app teams that fully own the backend
Origin-handled CORS, but only if OPTIONS is safely allowed through
Good if your app has nuanced origin rules or tenant-specific logic. Bad if platform and app teams don’t coordinate.
Best for security simplicity
Same-origin architecture
If you can proxy the API under the frontend origin, you’ll save yourself a lot of browser pain.
Worst default assumption
“Access will take care of it”
Nope. Access is not your CORS policy engine.
Common mistakes
- Using
Access-Control-Allow-Origin: *with credentials - Forgetting
Vary: Originwhen dynamically reflecting origins - Letting Access redirect preflight to login
- Not exposing headers the frontend actually needs
- Treating browser CORS failures as origin server bugs without checking edge behavior
- Applying broad unauthenticated bypass rules instead of
OPTIONS-only handling
If you’re also tightening other browser-side protections, this is where a broader header strategy helps. For things beyond CORS, like CSP and related policies, I’d point developers to https://csp-guide.com.
Practical recommendation
For most Cloudflare Access deployments, I’d choose one of these:
- Preferred: handle CORS preflight at the edge, keep Access on actual requests, and mirror the same CORS headers onto final responses
- Fallback: allow unauthenticated
OPTIONSthrough Access and let origin own CORS - Best if feasible: redesign to same-origin and remove CORS from the equation
The right answer depends less on Cloudflare and more on who owns routing, auth, and API behavior in your org. If platform owns edge behavior, do CORS there. If application teams need fine-grained logic, let origin handle it — but make sure preflight survives Access first.
That’s the whole game with CORS for Cloudflare Access: not writing headers, but deciding where they should be enforced so the browser, the edge, and the origin stop fighting each other.