CORS Error Explained: "Blocked by CORS Policy" and How to Fix It

A CORS error means the browser received the server's response but refused to hand it to your JavaScript, because the server did not say your page's origin is allowed to read it. The fix is on the server: it must send an Access-Control-Allow-Origin header naming your origin (and, for preflighted requests, answer the OPTIONS request with the allowed methods and headers).

Disabling CORS in your browser makes the error disappear on your machine only. Every real user still gets it.

The exact error messages

These were captured from Chrome 156 with a page on http://localhost:3000 calling an API on http://localhost:4000. The plain case, where the API sends no CORS headers:

Access to fetch at 'http://localhost:4000/api/orders' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Other browsers may word it differently; the cause is the same. When the request needed a preflight (here a PUT with a JSON body):

Access to fetch at 'http://localhost:4000/api/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.

And when the server sends * but the request includes cookies:

Access to fetch at 'http://localhost:4000/star/x' from origin 'http://localhost:3000' has been blocked by CORS policy: 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'.

In every case the console also logs Failed to load resource: net::ERR_FAILED, and your code only sees TypeError: Failed to fetch. MDN explains why: CORS failure details "are not available to JavaScript" for security reasons, so the console is the only place to read them.

What an "origin" is and what the same-origin policy blocks

An origin is the combination of scheme, host and port. MDN's examples, compared against http://store.company.com/dir/page.html:

URLSame origin?Why
http://store.company.com/dir2/other.htmlYesOnly the path differs
https://store.company.com/page.htmlNoDifferent scheme
http://store.company.com:81/dir/page.htmlNoDifferent port
http://news.company.com/dir/page.htmlNoDifferent host

That is why localhost:3000 calling localhost:4000 is cross-origin, even on one machine. The same-origin policy stops a script from reading responses from another origin. Without it, any website you visit could call your bank's API with your cookies and read the result. CORS is the opt-in that relaxes the rule: the Fetch standard describes it as "a set of headers that indicates whether a response can be shared cross-origin".

The request still reached the server

The test server logged every request. For the simple GET that failed, the log shows:

GET /api/orders origin=http://localhost:3000

The server received the request, ran it and sent a 200 with a body. The browser blocked only your script's access to the response. That has two consequences: the server's logs look fine while the browser shows an error, and CORS does not stop a simple request from having side effects on the server. Protecting state-changing endpoints is still the server's job.

Simple requests vs preflight requests

Some requests are sent directly; MDN calls them simple requests. Roughly: the method is GET, HEAD or POST, you set no headers beyond a small safelist, and any Content-Type is application/x-www-form-urlencoded, multipart/form-data or text/plain.

Anything else gets a preflight first: an OPTIONS request asking whether the real one is allowed. A PUT, a DELETE, an Authorization header or Content-Type: application/json all trigger it. The test server logged this for the JSON PUT:

OPTIONS /api/orders origin=http://localhost:3000 acrm=PUT acrh=content-type

That is the preflight carrying Access-Control-Request-Method: PUT and Access-Control-Request-Headers: content-type. If the server doesn't answer it with matching permission headers, the browser never sends the PUT at all. If your router has no handler for OPTIONS on that path, it may answer the preflight with 404 or 405, and the check fails.

The server-side fix

For the PUT above to succeed, the server answers the preflight with:

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

and includes Access-Control-Allow-Origin: http://localhost:3000 on the actual response too. With exactly these headers the test request returned 200 and the logs showed OPTIONS followed by PUT. A minimal Node.js version of that logic:

const ALLOWED = new Set(["http://localhost:3000", "https://app.example.com"]);

function cors(req, res) {
  const origin = req.headers.origin;
  if (ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.statusCode = 204;
    res.end();
    return true; // preflight answered
  }
  return false;
}

Most frameworks have a CORS middleware that does the same; configure it with an explicit list of allowed origins. Points that trip people up:

  • Echo one origin, not a list. The header holds a single origin or *. To allow several, check the request's Origin against an allowlist and echo it back, as above.
  • Add Vary: Origin when the header changes per request. The Fetch standard explains that without it, a cache can serve a response that lacks the header to a later CORS request.
  • Cookies need two headers. With credentials: "include", the server must name the exact origin (not *) and send Access-Control-Allow-Credentials: true.
  • Error responses need the headers too. If your 401 or 500 responses skip the CORS middleware, the browser reports a CORS error and hides the real status from your code.
  • Preflight caching. Access-Control-Max-Age: 86400 lets the browser reuse a preflight answer; MDN gives the default as 5 seconds.

Why disabling CORS in the browser is not a fix

Browser flags and "allow CORS" extensions switch off the check for you only. Your users' browsers still enforce it, so the bug ships. They also switch off a protection for every site you visit while they are active. Use them, if at all, for a few minutes of local debugging in a separate browser profile.

For local development, the cleaner option is to make the calls same-origin: if your dev server can proxy a path such as /api to your back end, so the browser sees one origin and CORS never applies. If you are calling a third-party API that doesn't send CORS headers, call it from your server instead; that API is telling you it isn't meant to be called from browsers.

If it still fails

Open the Network tab, find the failed request, and check whether there is an OPTIONS entry before it. Look at the response headers that actually arrived: a typo in the origin (https vs http, a trailing slash, a missing port) is enough to fail the match. If the preflight itself returns 4xx or 5xx, the status code is the clue; HTTP status codes explained covers what each means. And if the server isn't running at all, the request fails with a network error before CORS is involved; check for port conflicts when the API won't start.

Frequently asked questions

Can I fix a CORS error in my front-end code?
No. CORS permission comes from response headers sent by the server you are calling. Front-end code can only change what it sends, for example avoiding a custom header so no preflight is needed. If you don't control the API, call it from your own back end or a same-origin proxy instead of from the browser.
Does mode: 'no-cors' fix the error?
It gives you an opaque response: in a test in Chrome 156, response.type was 'opaque', the status was 0, there were no readable headers and the body read as an empty string. It is only useful for fire-and-forget requests where you don't need the answer.
Why does the API work in Postman or curl but not in the browser?
CORS is enforced by browsers, to protect users from one website reading another site's data with their cookies. curl and Postman are not browsers and don't apply the same-origin policy, so they show the response that the browser is hiding from your script.
Is Access-Control-Allow-Origin: * safe?
It is fine for public data that requires no login, such as a public API or font files. It cannot be used with cookies or other credentials; browsers reject * when the request's credentials mode is include. For anything user-specific, return the exact allowed origin instead.

Sources