Notes
Response to preflight request doesn't pass access control check:It does not have HTTP ok status
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
Authorizationheader, or any custom header such asX-Api-Key. - A PUT, PATCH or DELETE.
- Less obvious: any event listener on
xhr.upload, for example for upload progress, or aReadableStreamas 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:
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
- 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" - Match the status to a layer. 401 or 403: authentication (02). 404: the URL, or a router with no
OPTIONSroute (04, 01). 405: a router or framework that does not acceptOPTIONSon that path (01). 500, or a 400 from your own error handler: code written for the real request (03). - Answer
OPTIONSin the first layer that sees it, with 204 or 200 and three headers:Access-Control-Allow-Originwith the exact page origin,Access-Control-Allow-Methodswith the real method, andAccess-Control-Allow-Headerswith every name the browser listed inAccess-Control-Request-Headers. AddAccess-Control-Allow-Credentials: trueif the real request is sent withcredentials: 'include'orwithCredentials. - 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-Originis exactly your page's origin, or*if the request is not sent withcredentials: 'include'orwithCredentials. A bearer token inAuthorizationdoes not count as credentials here.- Every name you passed in
Access-Control-Request-Headersappears inAccess-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-Originto 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 inAccess-Control-Allow-Headersas well. preflightContinue: truein 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 answersOPTIONS.Access-Control-Allow-Headers: *to cover the token. The Fetch Standard never lets the wildcard coverAuthorization: it has to be listed by name. With credentials,*inAccess-Control-Allow-MethodsandAccess-Control-Allow-Headersdoes 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
OPTIONSin 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
- MDN, "Preflight request" (OPTIONS with Access-Control-Request-Method, Origin and Access-Control-Request-Headers, issued automatically by the browser): developer.mozilla.org/en-US/docs/Glossary/Preflight_request
- MDN, "Cross-Origin Resource Sharing (CORS)" (simple requests, upload listeners and ReadableStream, "CORS-preflight requests must never include credentials", credentialed requests via credentials: "include" or withCredentials, wildcards and credentials): developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- MDN, "CORS-safelisted request header" (Accept, Accept-Language, Content-Language, Content-Type with three values, Range): developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
- MDN, "CORS errors", client-side considerations (avoid triggering a preflight, use a proxy server): developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors
- WHATWG Fetch Standard, "CORS-preflight fetch" (a new OPTIONS request with Accept, Access-Control-Request-Method and Access-Control-Request-Headers; success needs a CORS check and an ok status; Authorization is never covered by the wildcard): fetch.spec.whatwg.org/#cors-preflight-fetch
- WHATWG Fetch Standard, "ok status" (200 to 299), "HTTP responses" (preflight restricted to an ok status such as 200 or 204; credentials mode of a preflight is always same-origin) and "HTTP fetch" (a failed preflight returns a network error): fetch.spec.whatwg.org/#ok-status, fetch.spec.whatwg.org/#http-responses, fetch.spec.whatwg.org/#http-fetch
- WHATWG Fetch Standard, "HTTP-network-or-cache fetch" (the browser appends Origin, Referer, the Fetch metadata headers and User-Agent to the request it sends, and a note there lists Host among the headers appended at that point), "CORS check" (
*passes only when the credentials mode is not "include") and "credentials" (HTTP cookies, TLS client certificates and HTTP authentication entries): fetch.spec.whatwg.org/#concept-http-network-or-cache-fetch, fetch.spec.whatwg.org/#concept-cors-check, fetch.spec.whatwg.org/#credentials - Chromium source,
CheckPreflightAccess(the origin check runs first; "It does not have HTTP ok status" only when it passed and the status is not 2xx) and the redirect handler for preflights: source.chromium.org/chromium/chromium/src/+/main:services/network/cors/preflight_controller.cc - Chromium source,
cors_error_string.cc(the "Access to ... at" prefix and the exact preflight messages): source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/platform/loader/cors/cors_error_string.cc - axios README ("Make XMLHttpRequests from the browser"): github.com/axios/axios
- expressjs/cors README (pre-flight handled for all routes with app.use(cors()), preflightContinue, optionsSuccessStatus default 204): github.com/expressjs/cors
- Express, "Writing middleware" (middleware loaded first is executed first): expressjs.com/en/guide/writing-middleware.html
- Microsoft Learn, "Enable Cross-Origin Requests (CORS) in ASP.NET Core" (UseCors order, automatic preflight handling): learn.microsoft.com/en-us/aspnet/core/security/cors
- Go, package net/http (MethodOptions, StatusNoContent, Header, WriteHeader): pkg.go.dev/net/http
- Supabase, "CORS support for invoking from the browser" (withSupabase handles CORS and preflight requests, OPTIONS check before the body without it, corsHeaders import from 2.95.0): supabase.com/docs/guides/functions/cors
- AWS, "CORS for REST APIs in API Gateway" (OPTIONS method with a mock integration; proxy integrations return the headers from the backend): docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors.html
- AWS, "Set up a method request in API Gateway" (proxy resource {proxy+}; the ANY method covers DELETE, GET, HEAD, OPTIONS, PATCH, POST and PUT): docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings-method-request.html
- RFC 9110, section 5.1 "Field Names" (case-insensitive): rfc-editor.org/rfc/rfc9110#section-5.1
- curl, "curl.1 the man page" (-i, -X, -H): curl.se/docs/manpage.html
- Stack Overflow, preflight answered with 405, accepted answer listing browser workarounds: stackoverflow.com/questions/35588699
- Stack Overflow, "It does not have HTTP ok status" with a Go and Twirp server, Access-Control-Allow-Origin set in the axios request: stackoverflow.com/questions/53298478
- Stack Overflow, "It does not have HTTP ok status" with ASP.NET 6, hand-written CORS middleware and a breakpoint that never hits: stackoverflow.com/questions/71195398