CORS errors decoded: every message mapped to its cause
Your app on https://staging.example.com calls your API on https://api-staging.example.com. It works in Postman. It works with curl. In the browser you get a paragraph of red text about a policy you did not write, and TypeError: Failed to fetch.
The message is long, but it is not vague. It names the exact header that is missing or wrong. Once you know how to read it, most CORS problems are a five-minute fix on the server.
Short version
- CORS is a browser rule. It only affects browsers, which is why curl and Postman never see it.
- The fix is almost always a response header on the server, not a change in your frontend code.
- The message tells you which header is wrong. Read past the first line.
- Some requests trigger a preflight — an extra
OPTIONSrequest the browser sends first to ask permission. - Credentials mode changes the rules: wildcards stop working and an extra header becomes required.
- A failed CORS request is often a 500 or a 404 in disguise. Check the Network tab before blaming CORS.
What CORS is, in one paragraph
By default a browser will not let JavaScript on one origin read a response from a different origin. An origin is the combination of scheme, host, and port — so https://example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:8080.
This rule is called the same-origin policy. Cross-Origin Resource Sharing, or CORS, is the opt-out: the server sends headers that say "this origin is allowed to read my response." No headers means no permission, and the browser hides the response from your code.
Two things follow, and both surprise people:
- The request usually still reached the server. The browser blocked the reading of the response, not the sending. A
POST /api/ordersthat "failed with CORS" may well have created the order. - You cannot fix it from the frontend. No fetch option makes the browser ignore the rule. The header has to come from the server, or from a proxy in front of it.
Preflight, explained simply
For simple requests the browser just sends them and checks the response headers afterwards. For anything else it asks first.
That advance question is the preflight: an OPTIONS request to the same URL, carrying two headers:
OPTIONS /v2/orders HTTP/1.1
Origin: https://staging.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization,content-type
The server must answer with 200 or 204 and permission headers:
Access-Control-Allow-Origin: https://staging.example.com
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Max-Age: 600
Only then does the browser send the real request.
A request is "simple" and skips preflight only if it uses GET, HEAD, or POST, sends no unusual headers, and its Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain.
In practice this means: Content-Type: application/json triggers a preflight, and so does an Authorization header. Almost every real API call is preflighted.
Access-Control-Max-Age tells the browser how long it may reuse the answer. Setting it to 600 removes the extra round trip for ten minutes and is a cheap performance win.
The messages, mapped
Chrome and Edge use the phrase "has been blocked by CORS policy" and then explain. Firefox uses "Cross-Origin Request Blocked" and gives a Reason:. Safari is the least specific. The table below uses Chrome wording.
| Message fragment | What actually happened | Fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present | The server sent no CORS headers at all. | Add Access-Control-Allow-Origin to the response. Check the server actually reached your CORS middleware. |
The 'Access-Control-Allow-Origin' header has a value 'https://app.example.com' that is not equal to the supplied origin | The server allows a different origin than the one you are on. | Add your origin to the allowed list. Watch for http vs https and a missing or extra port. |
Response to preflight request doesn't pass access control check | The OPTIONS request itself was rejected. | Make your server answer OPTIONS on that route with the permission headers. |
It does not have HTTP ok status | The preflight returned 404, 405, or 500. | Your router has no OPTIONS handler, or auth middleware rejected it. Never require a token on OPTIONS. |
Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response | The method is missing from the allow list. | Add it to Access-Control-Allow-Methods. |
Request header field x-request-id is not allowed by Access-Control-Allow-Headers in preflight response | You send a custom header the server did not permit. | Add the header name to Access-Control-Allow-Headers. The list is not case sensitive but must be complete. |
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' | You sent credentials: 'include' but the server did not opt in. | Add Access-Control-Allow-Credentials: true, or stop sending credentials. |
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include' | Wildcards and cookies cannot be combined. | Echo the exact origin instead of *, and add Vary: Origin. |
Redirect is not allowed for a preflight request | The OPTIONS request got a 301 or 302, often from an http-to-https or trailing-slash rule. | Call the final URL directly. Fix the trailing slash in your client. |
Request header field authorization is not allowed by Access-Control-Allow-Headers | Same as the custom header case, but for auth. | Include authorization in the allow list. |
The request client is not a secure context and the resource is in more-private address space | Private Network Access: a public page tried to reach localhost or a LAN address. | Serve the page over https, or use Access-Control-Allow-Private-Network: true on the target. |
Firefox: (Reason: CORS header 'Access-Control-Allow-Origin' missing) | Same as the first row. | Same fix. |
Firefox: (Reason: CORS request did not succeed) | The request never completed — DNS, TLS, connection refused, or an extension blocked it. | Not really CORS. Check the server is running and reachable. |
The three mistakes behind most CORS tickets
1. It is not a CORS problem
Failed to fetch with a CORS message often hides something simpler. Open the Network tab and look at the failing row.
- If the status is
500, the server crashed before the CORS middleware ran. Fix the crash and the CORS message disappears. - If the status is
404, the URL is wrong. - If the row says
(failed) net::ERR_CONNECTION_REFUSED, nothing is listening on that port.
Bad: "CORS is broken on staging."
Good: "
POST https://api-staging.example.com/v2/ordersreturns 500 with no CORS headers. The 500 is the real bug; CORS is the symptom."
2. The preflight is authenticated
Many frameworks put authentication middleware in front of every route. The browser sends OPTIONS without cookies or an Authorization header, so the middleware returns 401, and the browser reports a failed preflight.
The rule: OPTIONS requests must be answered before any auth check.
3. Wildcard plus credentials
This combination never works:
Access-Control-Allow-Origin: *
fetch(url, { credentials: 'include' });
When credentials are involved the server must name the origin exactly. The usual pattern is to keep a list of allowed origins, compare the incoming Origin header against it, echo the match back, and set Vary: Origin so caches do not serve one origin's response to another.
A checklist for debugging a CORS failure
- Open DevTools, go to Network, and tick Preserve log.
- Reproduce. Find the failing request. If there are two rows for the same URL, the first is the preflight.
- Click the preflight row. Confirm it returned 200 or 204. If not, that is your bug.
- Read the Response Headers of the preflight. Compare each allow header against what the browser asked for in
Access-Control-Request-MethodandAccess-Control-Request-Headers. - Check the origin string character by character.
https://staging.example.comandhttps://staging.example.com/are not the same value. - Retry the same URL with curl and an
Originheader to see the raw response:curl -i -X OPTIONS https://api-staging.example.com/v2/orders \ -H "Origin: https://staging.example.com" \ -H "Access-Control-Request-Method: POST" - If curl shows the headers but the browser does not, something between them — a CDN, a load balancer, a cache — is stripping or overwriting them.
For a bug report, include the request URL, the preflight response headers, the exact console message, and the origin you were on. Tools such as Crosscheck capture the console output and network requests with the report, which saves the back-and-forth of asking a tester to re-run the steps with DevTools open.
What not to do
- Do not disable web security in your browser to make the message go away. It hides the problem and it is not how your users run your app.
- Do not add a public CORS proxy to production traffic. You are routing your users' tokens through a stranger's server.
- Do not set
Access-Control-Allow-Origin: *on an authenticated API. It does not even work with credentials, and on a public endpoint it removes a real protection. - Do not "fix" it in the frontend.
mode: 'no-cors'does not bypass anything. It gives you an opaque response your code cannot read, which looks like success and is not.
The legitimate frontend-side workaround is a development proxy: your dev server forwards /api to the real API, so the browser sees one origin. That is fine locally, but it means CORS is untested until staging, so configure the real headers early.
Frequently asked questions
Why does the request work in Postman but not in the browser? CORS is enforced by browsers only. Postman and curl have no origin and no same-origin policy, so they never see the restriction.
Did my POST actually reach the server? If a preflight failed, no. If the real request was sent and only the response was blocked, yes — the server processed it. Check your server logs before retrying.
Why do I see two requests for the same URL?
The first is the preflight OPTIONS, the second is the real request. Seeing only the OPTIONS means the preflight failed.
Can I cache the preflight?
Yes. Set Access-Control-Max-Age to a value such as 600 seconds. Browsers cap it, but it still removes most repeat round trips.
Does CORS protect my API? Not on its own. It stops other websites from reading responses in a user's browser. Anything can still call your API directly, so keep your authentication and authorization checks.




