Handling CORS Preflight in Cloudflare Workers
Failure symptom:
Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Either the OPTIONS request is passing straight through the Worker to your origin (which returns 405 or omits CORS headers), or the Worker fetches the origin for OPTIONS and forwards a response that has no Access-Control-Allow-Origin at the edge.
Root Cause
A Cloudflare Worker intercepts every request to the route it is bound to. If your fetch handler unconditionally calls fetch(request) to the origin, the browser’s preflight OPTIONS is proxied to a backend that was never configured to answer it. The WHATWG Fetch Standard (§4.8, CORS-preflight fetch) requires the preflight response to carry Access-Control-Allow-Origin, Access-Control-Allow-Methods, and — when the request declared Access-Control-Request-Headers — a matching Access-Control-Allow-Headers. The browser does an exact string comparison; a missing header at the edge fails identically to a missing header at the origin. The fix is to let the Worker itself answer OPTIONS with a 204 before it ever contacts the origin.
This page is part of Proxy Bypass Strategies for CORS Preflight, which covers terminating preflight at reverse proxies, CDNs, and API gateways.
Prerequisite State
- A Worker is deployed and bound (via a route or a Pages Function) in front of
api.example.com. wrangleris installed and authenticated, so you can runwrangler deployandwrangler tail.- Your allowlist of permitted origins is known and version-controlled; you will embed it in the module.
- The actual (non-preflight) responses your origin returns are already correct JSON or similar — this page only fixes the CORS layer at the edge.
Step-by-Step Fix
Step 1 — Define the allowlist and a header builder
Keep the permitted origins in a Set at module scope so lookups are exact-match and cannot be bypassed by arbitrary reflection. Build the CORS headers from the validated origin only.
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
function corsHeaders(origin) {
const headers = new Headers({
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, X-Request-ID",
"Access-Control-Max-Age": "600",
"Vary": "Origin",
});
if (origin && ALLOWED_ORIGINS.has(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Credentials", "true");
}
return headers;
}
Step 2 — Short-circuit OPTIONS in the fetch handler
Detect request.method === "OPTIONS" first and return a 204 immediately. Because the Worker returns a Response here, fetch() to the origin is never called for preflight.
export default {
async fetch(request) {
const origin = request.headers.get("Origin");
// Preflight: answer at the edge, never reach the origin
if (request.method === "OPTIONS") {
// Only treat it as a CORS preflight when the required fields are present
if (origin && request.headers.get("Access-Control-Request-Method")) {
return new Response(null, { status: 204, headers: corsHeaders(origin) });
}
// Bare OPTIONS (not a CORS preflight): let the origin decide
return fetch(request);
}
// Step 3 handles the real request
return handleActual(request, origin);
},
};
Step 3 — Add CORS headers to the actual response
The preflight only authorises the browser to send the real request; the response to that request must carry the CORS headers too. Copy the origin’s response into a mutable one and merge the CORS headers.
async function handleActual(request, origin) {
const response = await fetch(request);
// Clone into a mutable response so we can append headers
const mutable = new Response(response.body, response);
for (const [key, value] of corsHeaders(origin)) {
mutable.headers.set(key, value);
}
// Ensure Vary: Origin is present even if the origin already set Vary
mutable.headers.append("Vary", "Origin");
return mutable;
}
The complete module — Steps 1 through 3 combined — is a valid modern Workers module-syntax Worker: paste the ALLOWED_ORIGINS set, corsHeaders, handleActual, and the export default block into a single src/index.js and run wrangler deploy.
Verification
curl the preflight — confirm the Worker answers 204 at the edge without touching the origin:
curl -sI -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: Authorization, Content-Type'
Expected: HTTP/2 204, plus Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Max-Age: 600, and Vary: Origin.
wrangler tail — stream live Worker logs while you repeat the curl to confirm the request is handled at the edge and no origin fetch fires for OPTIONS:
wrangler tail --format pretty
Rejected origin — send an origin not on the allowlist and confirm Access-Control-Allow-Origin is absent:
curl -sI -X OPTIONS https://api.example.com/v1/orders \
-H 'Origin: https://attacker.example' \
-H 'Access-Control-Request-Method: POST' | grep -i access-control-allow-origin
Expected: no output.
Security Boundary Note
Do not reflect the incoming Origin header unconditionally. Writing headers.set("Access-Control-Allow-Origin", request.headers.get("Origin")) without the ALLOWED_ORIGINS.has(origin) check turns your Worker into an open relay: every browser page on the internet, including attacker-controlled ones, is granted cross-origin access — and combined with Access-Control-Allow-Credentials: true that exposes authenticated data. Always validate against a fixed allowlist first, and only then echo the exact matched value. For the full pattern, see Dynamic Origin Validation Patterns and Credential Sharing & Security Boundaries.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Calling fetch(request) for every method, including OPTIONS |
Preflight is proxied to an origin that returns 405 or no CORS headers; browser blocks the real request |
Branch on request.method === "OPTIONS" and return a 204 before any origin fetch |
Reflecting Origin without an allowlist check |
Any origin gains credentialed cross-origin access to the API | Gate the echo behind ALLOWED_ORIGINS.has(origin); never reflect arbitrary values |
Adding CORS headers only to the 204, not to the real response |
Preflight passes but the browser blocks the actual GET/POST response for missing Access-Control-Allow-Origin |
Merge corsHeaders(origin) into the response returned by handleActual too |
Omitting Vary: Origin |
Cloudflare’s cache may serve one origin’s response to another origin | Set Vary: Origin on both the preflight and the actual response |
FAQ
Does the Worker OPTIONS handler run before my origin is contacted?
Yes, if you return the 204 from the fetch handler before calling fetch() to the origin. A Worker sits in front of the origin, so returning a Response for OPTIONS short-circuits the request and the origin never sees the preflight. Only call fetch(request) for non-OPTIONS methods.
Why should I echo the Origin instead of returning a wildcard?
A wildcard Access-Control-Allow-Origin cannot be combined with credentials, and it grants access to every origin. Echoing a validated Origin lets you support credentialed requests and restricts access to your allowlist. Always validate the incoming Origin against a fixed set before echoing it, and add Vary: Origin so caches key on it.
Do I still need CORS headers on the actual GET or POST response?
Yes. The preflight 204 only authorises the browser to send the real request. The response to that real request must itself carry Access-Control-Allow-Origin (and Access-Control-Allow-Credentials when credentials are used) or the browser blocks the response even though the preflight passed.