// concept

CORS preflight — why your fetch fails before it sends

Updated 2026-05-10

The error message everyone has seen

Access to fetch at 'https://api.example.com/v1/orders' from origin 'http://localhost:3000' 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.

You then check the network tab and notice — the browser sent an OPTIONS request, not the POST you wrote. The OPTIONS got 404 or 200 without the right headers, and the real request never fired.

That OPTIONS is the preflight. CORS makes the browser ask permission before any "non-simple" cross-origin request.

What makes a request "non-simple"

The browser will preflight a cross-origin request if any of these are true:

  • Method is anything other than GET, HEAD, POST.
  • Content-Type is anything other than application/x-www-form-urlencoded, multipart/form-data, or text/plain. (application/json triggers preflight.)
  • You set any header beyond a small allow-list (CORS-safelisted headers — Accept, Accept-Language, Content-Language, Content-Type with the safe values above).
  • You set credentials: 'include' and any of the above also apply.

In practice almost every modern API call preflights, because every modern API uses Content-Type: application/json and an Authorization header.

What the preflight asks

The browser sends:

OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type

It's saying: "I have origin localhost:3000, I want to send POST with these headers — is that allowed?"

The server answers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400

If those headers are present and match what was asked, the browser sends the real POST. If anything's missing, the browser blocks the real request and prints the error you saw.

The four most common bugs

1. The OPTIONS handler doesn't exist. Your framework treats OPTIONS as 404. Solution: register a wildcard OPTIONS handler that returns 204 with the right headers, or use a CORS middleware (cors() in Express, fastapi-cors, Rails' rack-cors).

2. Access-Control-Allow-Origin: * with credentials. If the request has credentials: 'include', the response cannot use * — it must echo the specific origin. Browsers reject * + credentials by spec.

3. Preflight cached against the wrong origin. Browsers cache the preflight response per (origin, method, path) tuple. If you have multiple front-ends hitting the same API, the first one's preflight can pollute the cache for others. Use Vary: Origin on the preflight response.

4. Forgot to allow the actual headers used. You add X-Request-ID to your client, the server's Allow-Headers list doesn't include it, preflight fails. Either dynamically echo Access-Control-Request-Headers back, or maintain an explicit list and update it when you add a header.

Debugging through a tunnel

When your front-end is at https://app.lrok.io and your API is at https://api.lrok.io, you have a real two-origin setup — same as production. CORS will fail in the tunnel exactly the way it would in production, so you catch the bug before deploying.

The /tools/cors-tester on lrok.io fires a real preflight from the browser to a URL of your choice and shows you the exact headers the server returned. Useful for "is the server right or is my client wrong?" questions.

$ lrok http 3000 --hint app
$ lrok http 4000 --hint api
# now app.lrok.io -> :3000 (front-end), api.lrok.io -> :4000 (API)

When to skip CORS entirely

If you control both sides, you can put them on the same origin (reverse-proxy your API under /api of your front-end) and CORS doesn't apply. This is the simplest production layout and removes a whole class of bug. CORS is a tax on cross-origin convenience.

If you can't avoid cross-origin, ship CORS middleware that:

  • Reflects the request's Origin (against an allow-list).
  • Sets Allow-Methods, Allow-Headers, Allow-Credentials correctly.
  • Returns 204 on OPTIONS, falls through on everything else.
  • Ships Vary: Origin.

A 10-line middleware solves it for the rest of the project.

// shipping?

lrok gives your localhost a public HTTPS URL with a reserved subdomain on the free plan. $9/mo flat for unlimited.

Related