Cloudflare Workers CORS Headers — Preflight Pattern

Cloudflare Workers CORS

Your Worker API works in curl and fails in the browser with a CORS error on a “non-simple” request. PUT with Content-Type: application/json triggers a preflight OPTIONS. If the Worker only implements POST/GET and returns 404/405 on OPTIONS without ACAO headers, the browser hides the real response and shows a CORS failure. The fix is one shared CORS helper — not copy-pasted headers on every route.

Centralize the header builder

const ALLOWED = new Set([
  "https://app.example.com",
  "http://localhost:5173",
]);

function corsHeaders(req: Request): HeadersInit {
  const origin = req.headers.get("Origin") ?? "";
  const allow = ALLOWED.has(origin) ? origin : "";
  return {
    "Access-Control-Allow-Origin": allow,
    "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
    "Access-Control-Allow-Headers":
      "Content-Type, Authorization, X-Requested-With",
    "Access-Control-Max-Age": "86400",
    Vary: "Origin",
  };
}

function withCors(req: Request, res: Response): Response {
  const headers = new Headers(res.headers);
  const extra = corsHeaders(req);
  for (const [k, v] of Object.entries(extra)) {
    if (v) headers.set(k, v);
  }
  return new Response(res.body, { status: res.status, headers });
}

Reflecting any Origin with * and cookies is wrong. Reflect an allowlist. Always Vary: Origin when the ACAO value depends on Origin.

Handle OPTIONS once at the edge of the router

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.method === "OPTIONS") {
      return withCors(req, new Response(null, { status: 204 }));
    }

    const url = new URL(req.url);
    let res: Response;
    try {
      res = await route(req, env, url);
    } catch (e) {
      res = Response.json({ error: "internal" }, { status: 500 });
    }
    return withCors(req, res);
  },
};

Preflight usually does not need a body. 204 with the right allow headers is enough. Put withCors on error responses too — a 500 without CORS headers still looks like a CORS bug in DevTools.

Credentials

If the browser sends cookies (credentials: 'include'):

"Access-Control-Allow-Credentials": "true",
// Access-Control-Allow-Origin must be an explicit origin, never *

Your fetch on the client must match. Mismatch = opaque failure mode that wastes hours.

Workers-specific traps

TrapWhat happens
CORS only on success pathBrowser reports CORS on 4xx/5xx
* plus Authorization header expectationsBrowser may reject credentialed flows
Different subdomain forgotten in allowlistWorks on www, fails on app
Pages Function + Worker both set CORSDuplicate/conflicting headers
Caching PREFLIGHT without VaryWrong origin served from cache

For Cloudflare Cache, do not cache authenticated API responses across users. If you cache GETs, include Origin in the cache key when CORS headers vary.

Debugging from the browser

  1. Network tab: find OPTIONS; confirm 204/200 and ACAO/ACAH/ACAM.
  2. Confirm the subsequent PUT/POST also includes ACAO (not only OPTIONS).
  3. Compare Origin exactly (scheme + host + port).
  4. curl the OPTIONS with Origin and Access-Control-Request-Method headers to see raw Worker output without browser filtering.
curl -i -X OPTIONS https://api.example.com/v1/items \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type,authorization"

Ship checklist

Verify from the real SPA origin: simple GET, POST JSON (preflight + actual), credentialed requests if you use cookies, and a deliberate disallowed origin that must not reflect ACAO. Log rejected Origin values at debug level to catch www vs apex mistakes. When Pages and a Worker share related hostnames, pick one layer to own CORS so headers do not duplicate or conflict.

One preflight handler, one allowlist, wrap every response. That pattern scales past the first route without spreading CORS folklore across the codebase.