Notes

Response to preflight request doesn't pass access control check:It does not have HTTP ok status

CORS Doctor

Your frontend calls an API, nothing reaches the handler, and the browser console prints this:

Access to XMLHttpRequest at 'https://api.example.com/items' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

Before your actual request, the browser sent an OPTIONS request to the same URL to ask whether the real one is allowed. That is the preflight. The server answered it with a status outside 200 to 299, and since a preflight only counts with a 2xx, the browser stopped there. Your POST, PUT or DELETE never left the browser. That is why a breakpoint in its handler never hits, and why the same call works in Postman or curl: they are not browsers and do not send preflights.

The exact wording narrows it down further. In Chromium's source (Chrome and the browsers built on it), "It does not have HTTP ok status" is only reported after the preflight response has already passed the Access-Control-Allow-Origin check. A missing header gives "No 'Access-Control-Allow-Origin' header is present" after the same prefix, and a redirect gives "Redirect is not allowed for a preflight request". So your CORS headers most likely do reach the browser. The status is the problem: some layer of your server answered the OPTIONS request with an error, typically 401, 403, 404, 405 or 500.

XMLHttpRequest in the message means the call went through XHR, which is what axios uses in the browser. A fetch() call says Access to fetch at instead. The causes and fixes below are the same for both. If your message ends in "No 'Access-Control-Allow-Origin' header is present", the failing response lacks the header as well, and that case has its own note.

Why the browser sent an OPTIONS request first

The browser skips the preflight only for a simple request: the method is GET, HEAD or POST, every header you set is CORS-safelisted, and Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain. A typical API call breaks at least one of those rules:

  • A JSON body, sent with Content-Type: application/json.
  • An Authorization header, or any custom header such as X-Api-Key.
  • A PUT, PATCH or DELETE.
  • Less obvious: any event listener on xhr.upload, for example for upload progress, or a ReadableStream as the body.

The preflight itself carries very little. The Fetch Standard builds it as a new request that carries none of your own headers, Authorization included: it adds only Accept, Access-Control-Request-Method and Access-Control-Request-Headers, next to the headers the browser adds on its own, such as Host, Origin and User-Agent. So there is no body and no cookies, and your own headers are only named. DevTools will show you more lines than this:

The CORS-relevant part of what the browser sends

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

To pass, the answer needs a 2xx status (the standard gives 200 and 204 as examples) and headers that cover the request that will follow:

What it needs back

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type

If the real request is sent with credentials, which here means credentials: 'include' in fetch() or xhr.withCredentials = true (so cookies, HTTP authentication or TLS client certificates can go along), the preflight answer also needs Access-Control-Allow-Credentials: true, and * stops working as a wildcard in these headers: name the origin, the methods and the headers explicitly. A bearer token you set in the Authorization header yourself does not make the request credentialed in this sense, but Authorization still has to be listed by name in Access-Control-Allow-Headers.

Where the preflight gets its error status

On most servers the OPTIONS request walks through the same layers as any other request. If the CORS layer only adds headers and lets it continue, the first layer that does not expect a preflight picks the status:

Where the preflight gets its error status The browser sends OPTIONS /items with no body, no cookie and no token, and it passes the server's layers in order. One: the CORS layer adds the Access-Control-Allow headers that pass Chrome's origin check; the fix is to answer OPTIONS here with 204 and stop. Two: the auth middleware finds no token or cookie and returns 401 Unauthorized or 403 Forbidden. Three: the router has no OPTIONS route, or the URL is mistyped, and returns 404 Not Found or 405 Method Not Allowed. Four: the handler reads a body the preflight does not have and returns 500 Internal Server Error. As long as the headers from layer one survive, any of these ends in: It does not have HTTP ok status. The real request is never sent. OPTIONS /items: no body, no cookie, no token 1 CORS layer Adds the Access-Control-Allow-* headers Headers that pass Chrome's origin check Fix: answer OPTIONS here with 204, stop 2 Auth middleware Finds no token or cookie, rejects it RETURNS 401 Unauthorized or 403 Forbidden 3 Router No OPTIONS route, or a mistyped URL RETURNS 404 Not Found or 405 Method Not Allowed 4 Handler Reads a body the preflight does not have RETURNS 500 Internal Server Error It does not have HTTP ok status. The real request is never sent.
The preflight passes through your server in the same order as any request. Once layer 1 has added the headers, any 4xx or 5xx from a later layer ends in this exact message, as long as the headers survive on that error response. The fix sits in layer 1: answer OPTIONS there with a 2xx and do not pass it on.

The four causes below map onto these layers: 01 is the router answering an OPTIONS request that layer 1 let through, 02 is the auth middleware, 03 is the handler, and 04 is the router again, for a URL that does not exist.

01

The CORS code adds headers, then passes OPTIONS on

A middleware sets the Access-Control-Allow-* headers on every response, which is enough for the origin check, and then hands the OPTIONS request to the router like any other request. The router has no OPTIONS route for that path, answers 404 or 405, and the headers ride along on the error. In the Go question linked below, a wrapper set the headers and then called the Twirp handler for every method. In the ASP.NET 6 one, hand-written middleware added the headers in front of a controller that only had an [HttpPost] action, and the asker noticed that the breakpoint never hit.

The fix is to end the preflight in the CORS layer itself: if the method is OPTIONS, send a 2xx and return without calling the next handler.

Go, net/http

func withCORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        h := w.Header()
        h.Set("Access-Control-Allow-Origin", "http://localhost:3000")
        if r.Method == http.MethodOptions {
            h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
            h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
            w.WriteHeader(http.StatusNoContent) // 204
            return // do not call next: the router would answer 404 or 405
        }
        next.ServeHTTP(w, r)
    })
}

Libraries do this for you, as long as you let them. In Express, app.use(cors()) from the cors package handles preflights for all routes and ends them with 204 by default. In ASP.NET Core, the built-in CORS middleware (AddCors plus UseCors) answers preflight requests automatically, which is usually a better choice than headers added by hand.

02

Authentication rejects the preflight

The preflight never carries credentials. The Fetch Standard says its credentials mode is always "same-origin", which excludes them for a cross-origin call. MDN puts it as "CORS-preflight requests must never include credentials". Your own Authorization header is only named in Access-Control-Request-Headers, never sent. So an auth middleware that sees the OPTIONS request finds no token and no session cookie, and returns 401 or 403. The real request would have had the token, but it is never sent.

With this exact message, something already puts the CORS headers on that 401 or 403, typically CORS code that runs before authentication and then lets the OPTIONS request continue. That makes this a variant of 01. The fix is the same: end the preflight with a 2xx in the CORS layer, so auth never sees it, or let the auth check skip OPTIONS, since a preflight never carries credentials anyway. With a library, load it before auth and let it answer the preflight. Express runs middleware in the order you load it:

Express, with the cors package

const cors = require('cors');

// 1. CORS first: answers every OPTIONS request with 204 and ends it
app.use(cors({ origin: 'http://localhost:3000' }));

// 2. auth only sees real requests (requireAuth is your own middleware)
app.use(requireAuth);

// 3. routes
app.post('/items', createItem);

In ASP.NET Core, Microsoft's documentation places UseCors after UseRouting and before UseAuthorization:

ASP.NET Core, Program.cs

builder.Services.AddCors(o => o.AddPolicy("app", p => p
    .WithOrigins("http://localhost:3000")
    .WithMethods("GET", "POST")
    .WithHeaders("Authorization", "Content-Type")));

app.UseRouting();
app.UseCors("app");      // after UseRouting, before UseAuthorization
app.UseAuthorization();

If authentication runs before any CORS code at all, the 401 carries no CORS headers either, and Chrome reports "No 'Access-Control-Allow-Origin' header is present" after the preflight prefix instead. That is the case where moving the CORS layer in front of authentication is the fix.

03

A function that does not expect OPTIONS throws

In a bare serverless function, nothing answers OPTIONS unless your code does. The preflight reaches code written for the real request, which parses a JSON body the preflight does not have, throws, and the function answers 500, or 400 if your own error handler catches it. Supabase's documentation for Edge Functions offers two ways out. The withSupabase wrapper from npm:@supabase/server handles CORS and the OPTIONS preflight for you. In a function without it, check the method first, before touching the body.

Supabase Edge Function without the wrapper, from the official example

// Handle the CORS preflight request.
if (req.method === 'OPTIONS') {
  return Response.json({ ok: true }, { headers: corsHeaders })
}

// only now read the body
const { name } = await req.json()

With supabase-js 2.95.0 or later, corsHeaders can be imported from npm:@supabase/supabase-js@^2/cors; for older versions, the docs define it in a _shared/cors.ts file. On AWS API Gateway REST APIs, a non-proxy integration needs an OPTIONS method with a mock integration that returns the three Access-Control-Allow-* headers. With a Lambda proxy or HTTP proxy integration, your backend returns those headers. If OPTIONS is routed to that integration, for example through an ANY method on {proxy+}, your function has to answer it.

04

The preflight goes to a URL that does not exist

The preflight is sent to exactly the URL of your request. A typo, a missing /api prefix, a path renamed in a refactor or the wrong port gives a 404 on OPTIONS. When a global layer adds CORS headers to every response, 404s included, this error is all you see of it. Take the URL from the error message, which is the one the preflight went to, and request it with curl as in the next section.

The fix, step by step

  1. Reproduce the preflight outside the browser. Copy the method and the header names from your real request. Leave out cookies and the token, because the browser leaves them out too.

    Terminal

    curl -i -X OPTIONS https://api.example.com/items \
      -H "Origin: http://localhost:3000" \
      -H "Access-Control-Request-Method: POST" \
      -H "Access-Control-Request-Headers: authorization,content-type"
  2. Match the status to a layer. 401 or 403: authentication (02). 404: the URL, or a router with no OPTIONS route (04, 01). 405: a router or framework that does not accept OPTIONS on that path (01). 500, or a 400 from your own error handler: code written for the real request (03).
  3. Answer OPTIONS in the first layer that sees it, with 204 or 200 and three headers: Access-Control-Allow-Origin with the exact page origin, Access-Control-Allow-Methods with the real method, and Access-Control-Allow-Headers with every name the browser listed in Access-Control-Request-Headers. Add Access-Control-Allow-Credentials: true if the real request is sent with credentials: 'include' or withCredentials.
  4. Run the same curl command again and check the answer against the next section.

How to tell it is fixed

curl should now print an answer shaped like "What it needs back" above:

  • The status is 200 or 204, not a 3xx, 4xx or 5xx. Header names are case-insensitive, so lowercase names in curl's output are fine.
  • Access-Control-Allow-Origin is exactly your page's origin, or * if the request is not sent with credentials: 'include' or withCredentials. A bearer token in Authorization does not count as credentials here.
  • Every name you passed in Access-Control-Request-Headers appears in Access-Control-Allow-Headers, in any letter case.

Then reload the page with DevTools open on the Network tab. You should see the OPTIONS request with a 2xx, followed by your real request. If the console now shows a different preflight message, such as "Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response" or "Method PUT is not allowed by Access-Control-Allow-Methods in preflight response", that is progress: the status is fixed, and one of the two lists is missing a name.

Things that look like fixes but are not

  • Adding Access-Control-Allow-Origin to the request. The Go question linked below did this in its axios config. It is a response header. On the request it is just a header that is not CORS-safelisted, so it forces a preflight on its own and then has to be listed in Access-Control-Allow-Headers as well.
  • preflightContinue: true in the Express cors package. It passes the preflight on to the next handler instead of ending it, which is exactly the fall-through from 01. Leave it at the default, false, unless a later handler really answers OPTIONS.
  • Access-Control-Allow-Headers: * to cover the token. The Fetch Standard never lets the wildcard cover Authorization: it has to be listed by name. With credentials, * in Access-Control-Allow-Methods and Access-Control-Allow-Headers does not count as a wildcard at all.
  • Fixing only the route you are testing. Every URL the frontend calls with a JSON body or a token gets its own preflight. Answer OPTIONS in one global place, not route by route.
  • Turning off web security in Chrome, or a CORS browser extension. The accepted answer on the most-viewed question lists these as workarounds. They change only your own browser, and every user still gets the error.

Check your preflight values

For this error, fill in the fields in CORS Doctor, not only the message. The message tells it that the preflight failed; the preflight's status and headers tell it which check failed and why (a 405 means something different from a 401).

Open CORS Doctor with this note's example filled in: a POST with Authorization and a JSON body, and a preflight answered with 405. Then replace the values with yours, from the curl output in step 1 or from the preflight in the Network tab: page origin, request URL, method, custom headers, Content-Type, the preflight status and its Access-Control-Allow-Origin. It tells you whether and why the request needs a preflight, flags a preflight status outside 2xx, and lists the Access-Control-Allow-Methods and Access-Control-Allow-Headers values the preflight must return, with a server snippet if you pick your stack (Express, FastAPI, Django, Rails, Laravel, Go, ASP.NET Core, Supabase Edge Functions, Firebase Cloud Functions, Cloudflare Workers or S3). The check runs in your browser and does not call your API.

Questions

Why does the same request work in Postman, Insomnia or curl?

Preflights are sent by browsers. Postman, Insomnia and curl send only the request you give them, so the OPTIONS request that fails in the browser never happens there. That also makes curl the right tool for testing the preflight: send the OPTIONS request by hand, with the same Origin and Access-Control-Request-* headers the browser would send.

Why does my breakpoint in the POST handler never hit?

Because the POST was never sent. The browser sends the actual request only after a successful preflight. When the preflight fails, the whole fetch ends with a network error. Put the breakpoint or a log line on the OPTIONS request instead.

Should the preflight return 200 or 204?

Either. The Fetch Standard accepts any status from 200 to 299 and names 200 and 204 as examples. The Express cors package answers with 204 by default and has an optionsSuccessStatus option because, according to its README, some legacy browsers (IE11, various SmartTVs) choke on 204.

Can I avoid the preflight instead of fixing it?

Only if the request qualifies as simple: GET, HEAD or POST, only CORS-safelisted headers, and a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. A JSON body or an Authorization header rules that out. If you do not control the API, MDN describes the other way: route the call through a server you control, which fetches the resource for you.

Sources