http
CORS Errors Explained: Why curl Works and fetch Doesn't
The server got your request. The browser just won't let your JavaScript read the reply. What preflight is, the three wrong fixes, and the missing header.
You paste the URL into curl and get a clean 200 with JSON. You put the same URL in fetch() and the console turns red: No 'Access-Control-Allow-Origin' header is present on the requested resource. CORS is not the server rejecting you. It’s the browser refusing to hand the response to your JavaScript, and in most cases the server already received the request and answered it. curl and Postman “work” because they are not browsers. They don’t enforce CORS at all.
Below: what the browser is actually checking, when it sends an OPTIONS preflight first, the three fixes that look like they should work and don’t, and a table that maps each Chrome console message to the header your server is missing. The error strings are copied from Chromium’s source (cors_error_string.cc). The preflight rules come from the Fetch Standard.
Who CORS protects
The same-origin policy exists for the user, not the server. Your browser holds cookies for your bank, your email, and your company’s intranet. If any page you visit could fetch() those sites and read the replies, any page could read your inbox. So by default the browser lets a page send certain cross-origin requests but won’t let its scripts read the response.
CORS is the opt-in. The server says “this origin may read my responses” with an Access-Control-Allow-Origin header, and the browser checks that header before giving the body to your code.
curl has no cookies from your other tabs and no page origin to protect, so there is nothing to enforce. It doesn’t even send an Origin header. Run curl -v and you’ll see only Host, User-Agent and Accept. Postman, server-side code, and our HTTP request builder are in the same position. That’s why “it works in curl” tells you the endpoint is up, and tells you nothing about CORS.
The request usually did reach the server
This is the part that surprises people. For a “simple” request (more on that below), the browser sends it, the server runs its handler, the database row gets written, the response comes back over the wire, and then the browser checks the headers and throws the body away. Your code gets TypeError: Failed to fetch and nothing else. MDN is blunt about it: the specifics of a CORS failure “are not available to JavaScript code,” and the console is the only place to see what went wrong.
So a POST that “failed with CORS” may still have created the order. Check your server logs before you retry it in a loop.
The Fetch Standard explains why simple requests are allowed through. A preflight is only performed “for requests that are more involved than what is possible with HTML’s <form> element.” A plain HTML form could already send a cross-site GET or form-encoded POST long before CORS existed, so blocking those at the network level would protect nothing.
When the browser sends a preflight first
Anything a form couldn’t do gets an OPTIONS request first, asking permission. The real request is only sent if that preflight passes. Per the Fetch Standard, a preflight happens when either of these is true:
- The method is not a CORS-safelisted method: anything other than
GET,HEADorPOST. - The request has a header that is not a CORS-safelisted request-header.
The safelisted headers are a short list, and each one comes with value rules:
| Header | Safelisted when |
|---|---|
Accept | Value has no unsafe bytes (like ", (, :, {) |
Accept-Language, Content-Language | Value is only letters, digits, space and *,-.;= |
Content-Type | Essence is application/x-www-form-urlencoded, multipart/form-data or text/plain |
Range | A single bytes= range with a start value (bytes=-500 doesn’t count) |
Each value must also be 128 bytes or shorter, and if the safelisted values add up to more than 1,024 bytes, they all stop counting as safe.
Two consequences catch almost everyone:
Content-Type: application/jsontriggers a preflight. It’s not on the list. Every JSON POST from a browser to another origin is preflighted.Authorizationtriggers a preflight. Any bearer token meansOPTIONSgoes first.
The preflight carries Origin, Access-Control-Request-Method and (if needed) Access-Control-Request-Headers. The server must answer with a 2xx status and headers that allow that origin, method and header list. If it returns 404, 405 or a redirect because nobody wrote an OPTIONS handler, the real request is never sent. Whenever the preflight fails, for any reason, the server never sees your POST.
The three wrong fixes
1. mode: 'no-cors'
Older versions of Chrome (through Chrome 135, based on the Chromium source) ended the missing-header error with: “If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.” Plenty of people took that advice. It makes the error disappear and gives you nothing.
A no-cors fetch returns an opaque filtered response. The spec defines it exactly: status 0, empty status message, empty header list, body null. You can’t read a single byte of it. On top of that, the spec throws a TypeError if the method isn’t GET, HEAD or POST, and it silently drops any header that isn’t safelisted. So your Authorization and Content-Type: application/json headers are quietly removed before the request leaves.
const res = await fetch('https://api.example.com/data', { mode: 'no-cors' })
res.type // "opaque"
res.status // 0
await res.text() // "" (nothing to read)
no-cors is meant for things like firing a beacon or caching a cross-origin image in a service worker, where you never need to look inside. Current Chromium no longer suggests it in that message.
2. Access-Control-Allow-Origin: * with credentials
The wildcard is fine for a public API. It stops working the moment the request sends cookies (credentials: 'include' in fetch, withCredentials = true in XHR). The spec’s CORS check only accepts * when the credentials mode is not include. With credentials, the header must be the exact requesting origin, and the response must also carry Access-Control-Allow-Credentials: true.
The same goes for Access-Control-Allow-Methods: * and Access-Control-Allow-Headers: *. The spec counts * as a wildcard only “for requests without credentials.” And even without credentials, * in Allow-Headers never covers Authorization. You have to list it by name.
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Authorization, Content-Type
Vary: Origin
Since the origin value is now specific, add Vary: Origin so a CDN doesn’t serve one origin’s response to another.
3. Adding CORS headers only to the real route
Your POST handler sets Access-Control-Allow-Origin perfectly. But the OPTIONS request hits a router that has no handler for it, or an auth middleware that returns 401 because preflights carry no token. The preflight fails, so the POST is never sent, and the console error starts with “Response to preflight request doesn’t pass access control check.” Make sure OPTIONS reaches your CORS logic before auth and returns 204 or 200.
While you’re there, set Access-Control-Max-Age. If it’s missing, the spec caches a preflight result for just 5 seconds, so a busy page re-asks constantly.
Which header is missing: read the Chrome message
Chrome’s console line always starts with Access to fetch at '<url>' from origin '<origin>' has been blocked by CORS policy: (or Access to XMLHttpRequest at for XHR). If the preflight failed, the next part is Response to preflight request doesn't pass access control check:. The rest tells you what to fix:
| Chrome message (after the prefix) | What’s wrong | Fix on the server |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present on the requested resource. | No ACAO at all | Return Access-Control-Allow-Origin with your page’s origin (or * if public and no cookies) |
The 'Access-Control-Allow-Origin' header has a value '...' that is not equal to the supplied origin. | ACAO names a different origin | Echo the request’s Origin if it’s on your allowlist. Watch http vs https, ports, and www |
The 'Access-Control-Allow-Origin' header contains multiple values '...', but only one is allowed. | Two layers both add ACAO (app plus proxy or CDN) | Set it in one place only |
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. | * plus cookies | Use the exact origin and add Access-Control-Allow-Credentials: true |
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. | Credentials header missing | Add Access-Control-Allow-Credentials: true |
It does not have HTTP ok status. (preflight) | OPTIONS returned 404, 405, 401 or 500 | Handle OPTIONS before auth and return 2xx |
Redirect is not allowed for a preflight request. | OPTIONS got a 301/302 (trailing slash, http to https) | Call the final URL directly |
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. | Header not listed | Add it to Access-Control-Allow-Headers |
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response. | Method not listed | Add it to Access-Control-Allow-Methods |
Firefox says the same things in different words, starting with “Cross-Origin Request Blocked” and a “Reason:” line such as CORS header 'Access-Control-Allow-Origin' missing.
WebSockets are the exception to all of this. They don’t use CORS. The browser sends an Origin header in the handshake and it’s the server’s job to check it (RFC 6455, section 10.2). If your socket connects and then drops, that’s a different problem, covered in why your WebSocket keeps disconnecting.
Checking it without a browser
Because CORS is browser-only, you can look at what the server sends by asking from somewhere that isn’t a browser. Run the same preflight Chrome would:
curl -i -X OPTIONS https://api.example.com/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization, content-type"
Look for a 2xx status and the Access-Control-Allow-* headers in the reply. Many servers only add them when an Origin header is present, so always include one.
You can do the same thing in the HTTP request builder. Pick OPTIONS, add those three lines in the headers box, and send. It sends your request through our Cloudflare Worker (that’s how it gets around CORS itself) and shows every response header. It follows redirects, though, so use the curl command above (no -L) to catch a redirecting preflight. The HTTP header analyzer lists all of a URL’s response headers too, but it sends a plain GET with no Origin, so it may not show the headers that only appear for cross-origin requests. If you copied a request out of DevTools with “Copy as cURL,” the curl converter turns it into fetch code. Keep in mind that a JSON body or an Authorization header in that code will trigger a preflight in the browser, even though curl never sent one. For POST-only webhook endpoints, the webhook tester shows the full response the same way.
If the curl preflight comes back 2xx with the right Access-Control-Allow-Origin, your server is fine and the bug is in the request your page builds. If it comes back 404 or with no CORS headers, the row in the table above tells you which header to add.