Cloudflare Workers CORS Headers — Preflight Pattern
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
| Trap | What happens |
|---|---|
| CORS only on success path | Browser reports CORS on 4xx/5xx |
* plus Authorization header expectations | Browser may reject credentialed flows |
| Different subdomain forgotten in allowlist | Works on www, fails on app |
| Pages Function + Worker both set CORS | Duplicate/conflicting headers |
| Caching PREFLIGHT without Vary | Wrong 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
- Network tab: find OPTIONS; confirm 204/200 and ACAO/ACAH/ACAM.
- Confirm the subsequent PUT/POST also includes ACAO (not only OPTIONS).
- Compare
Originexactly (scheme + host + port). - curl the OPTIONS with
OriginandAccess-Control-Request-Methodheaders 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.