Skip to main content
All posts
Edge and Serverless7 min read

Cloudflare Workers Reject POST Requests with a CORS Preflight Failure

A Worker that served GET requests without complaint started rejecting every POST from the browser with an opaque CORS error. The request never reached my handler at all, because the preflight was answered long before my routing logic ran.

Portrait of Benjamin Fazli, the author of bfzli.com

Benjamin Fazli

Principal EngineerSkopje, North Macedonia

Network cables plugged into a switch, lit in purple, representing edge network routing

The symptom

A Worker deployed behind a custom domain answered every GET request without complaint. The moment the browser sent a POST with a JSON body, the request failed before it reached application code, and the console printed the familiar line about the preflight response not passing the access control check.

The Worker logs showed nothing at all. That was the first real clue. If the handler had run and thrown, there would be an entry. An empty log means the browser never got far enough to send the request I was trying to debug.

What is actually happening

A POST carrying Content-Type: application/json is not a simple request. The browser sends an OPTIONS request first and waits for permission. My router only had branches for GET and POST, so the OPTIONS request fell through to the default case and came back as a 405 with no CORS headers on it.

From the browser point of view that is a refusal, so it cancels the real request and reports a CORS failure rather than a 405. The status code you actually need in order to debug this never appears as the failing entry in the network tab.

The fix

Answer the preflight explicitly, before any routing, and repeat the same allow headers on the real response:

js
const cors = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400'
}

export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: cors })
    }

    const response = await route(request)
    const headers = new Headers(response.headers)

    for (const [key, value] of Object.entries(cors)) headers.set(key, value)

    return new Response(response.body, { status: response.status, headers })
  }
}

Three details matter more than the rest:

  • Access-Control-Allow-Headers has to name every non standard header you send. Authorization is the one people forget, and it fails in exactly the same way as a missing origin.
  • The allow headers must also be present on the real response. A passing preflight followed by a bare response fails just as loudly.
  • If the request carries credentials, Access-Control-Allow-Origin cannot be a wildcard. Echo the specific origin back after checking it against an allowlist.
As soon as Access-Control-Max-Age is set, the browser caches the preflight result. While you are debugging, keep that value low or you will spend twenty minutes testing a correct fix against a cached rejection.

What I check first now

I open the network tab, filter for OPTIONS, and read the response to the preflight rather than the failing request underneath it. Nine times out of ten the answer is a status code the router returned by accident.