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

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.

Worker fetch handler: preflight branch vs actual-request branch A request enters the Worker fetch handler. If the method is OPTIONS with an Origin and Access-Control-Request-Method, the Worker returns a 204 with CORS headers directly to the browser and never contacts the origin. Otherwise the Worker fetches the origin and merges CORS headers into the response. Browser Worker fetch() method === OPTIONS? yes Return 204 + CORS headers (edge) 204 No Content no (GET/POST/…) fetch(origin) merge CORS headers origin never sees OPTIONS

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.