First things first: your request probably worked

The thing almost every guide gets wrong, or at least buries: a CORS error does not mean your request failed to reach the server. In most cases the browser sent the request, the server processed it, returned a 200, and the browser then refused to hand the response to your JavaScript. Check your server logs while the console shows a CORS error. The request is right there.

That’s because CORS is not a firewall, it’s a read permission. Browsers enforce the same-origin policy, a rule that goes back to Netscape Navigator 2.0 in 1995, the same release that introduced JavaScript. Scripts on origin A don’t get to read responses from origin B. CORS (a W3C recommendation since January 2014, now folded into the WHATWG Fetch standard) is the opt-in mechanism that lets origin B say “this origin may read me” via response headers.

So the mental model that makes everything click: the server grants permission, the browser enforces it, and your frontend code is just the bystander that gets told no. Which explains the classic confusion, too: curl and Postman never show CORS errors because they aren’t browsers and enforce nothing.

What your exact message means

Chrome writes a different sentence for each cause, and the sentence is the diagnosis. Find yours:

No 'Access-Control-Allow-Origin' header is present on the requested resource.

The server answered without any CORS header at all, so the browser has nothing to check against. Either the CORS middleware never ran for this route, or it ran and decided your origin is not on the list. Look at the raw response headers in the Network tab rather than the console: an empty result there means the middleware, not the origin list, is the problem.

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

The OPTIONS request came back with something other than a 2xx, usually a 401 from an auth middleware or a 404 because no route is registered for OPTIONS. Preflights carry no cookies and no Authorization header by design, so any middleware that demands credentials will reject them. Let OPTIONS through before authentication runs.

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.

The named header is missing from the preflight answer. Every non-safelisted request header has to appear in Access-Control-Allow-Headers, and the list is not inherited from anywhere. The field name in the message is the one to add.

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'.

Wildcards and credentials are mutually exclusive, which is the rule that turns a working setup into a broken one the day someone adds cookies. Echo the requesting origin back instead of sending *, add Vary: Origin so caches keep the answers apart, and validate the origin against an allowlist before echoing it.

Redirect is not allowed for a preflight request.

Something answered the OPTIONS request with a 301 or 302, typically an http-to-https rule or a trailing-slash normalisation in front of the application. Preflights do not follow redirects. Point the request at the final URL, or exempt OPTIONS from the redirect rule.

Why you can’t catch it in JavaScript

Try to handle a CORS failure in code and you get remarkably little. fetch() rejects with a bare TypeError: Failed to fetch in Chrome, NetworkError when attempting to fetch a resource in Firefox. XHR reports status: 0. No status code, no headers, no hint that CORS was the reason. The useful message exists only in the devtools console.

Annoying, but deliberate. If JavaScript could inspect why a cross-origin request was blocked, it could probe your internal network: distinguish “host exists but blocked” from “host doesn’t exist”, map intranet services, that kind of thing. So the spec flattens every cross-origin failure into the same opaque network error. Your monitoring can’t tell CORS from a dropped connection either, which is worth knowing before you burn an afternoon on Sentry reports that just say “Failed to fetch”.

Practical consequence: debug CORS in the Network tab, not in your code. Look at the actual response headers of the blocked request (the browser did receive them) and compare against what the error message in the console says it wanted.

Preflight: the OPTIONS request you didn’t send

CORS distinguishes “simple” requests from everything else. Simple requests go straight out, like a form submission would have in 1998. Everything else gets a preflight: the browser first sends an OPTIONS request asking for permission, and only sends your real request if the answer satisfies it.

What makes a request non-simple is narrower than most people think:

Stays simpleTriggers preflight
GET, HEAD, POSTPUT, PATCH, DELETE, anything else
Content-Type: form-urlencoded, multipart/form-data, text/plainContent-Type: application/json (yes, really)
Safelisted headers (Accept, Accept-Language, …)Authorization, X-Api-Key, any custom header

The JSON row is the one that bites everyone. The simple list is exactly what an HTML form could already send before CORS existed, so it added no new attack surface. application/json wasn’t on that list, so the single most common API request shape (POST with a JSON body) is always preflighted. If OPTIONS requests die somewhere on the way to your app, you get the signature pattern: GETs work, JSON POSTs fail.

Two lesser-known preflight facts that solve real mysteries:

  • Preflight results are barely cached. No Access-Control-Max-Age header means the result is cached for all of 5 seconds (the Fetch spec default). And you can’t cache generously either: Chromium caps the value at 7200 seconds (2 hours) no matter what you send, Firefox at 86400 (24 hours). If your API sees a steady stream of OPTIONS requests, that’s why.
  • Preflights must not redirect. A 301 from http to https, or a redirect adding a trailing slash, is a spec-level CORS failure. The error message doesn’t say “redirect”, it says the usual CORS boilerplate, so people rarely suspect their own rewrite rules. Call the https URL with the exact final path and the problem evaporates.

Fixes that actually fix it

Every real fix is server-side. The server needs to answer with Access-Control-Allow-Origin matching the requesting page’s origin (scheme + host + port, so http://localhost:3000 and http://localhost:5173 are different origins). For preflighted requests, the OPTIONS response additionally needs Access-Control-Allow-Methods and Access-Control-Allow-Headers covering what the real request will do, and it has to come back with a 2xx.

In Express that’s the cors middleware, in Spring it’s @CrossOrigin or a global CorsRegistry, in Django it’s django-cors-headers, and every framework has its version. Use it instead of hand-rolling headers; the hand-rolled versions are where the subtle bugs live.

If you do hand-roll (or run nginx in front), three details matter more than the rest:

  • nginx’s add_header ignores error responses. By default it applies to 2xx and 3xx only. Your 401s and 500s go out without CORS headers, the browser masks them as CORS errors, and you debug the wrong thing. The fix is one word: add_header Access-Control-Allow-Origin $origin always;.
  • Echoing origins needs an allowlist. Reflecting whatever Origin arrives back into the header is equivalent to * but also works with credentials, which is the worst of both worlds. Compare against a fixed list, echo on match, omit the header otherwise.
  • Send Vary: Origin when the header varies. Otherwise a cache (CDN or browser) stores the response with origin A baked in and serves it to origin B, producing CORS errors that depend on who visited first. These are miserable to reproduce.

Fixes that only look like fixes

The internet’s favourite CORS answers, ranked by how much time they waste:

  • Adding Access-Control-Allow-Origin to the request. The all-time classic, still upvoted on Stack Overflow. It’s a response header. Setting it on your fetch call does nothing except sometimes making things worse, because a custom header can turn a simple request into a preflighted one.
  • mode: 'no-cors'. Looks like an off switch, isn’t. It makes the response opaque: status 0, empty body, no headers readable. Your request “succeeds” and you can’t use the result for anything. It exists for fire-and-forget cases like beacons, not for reading data.
  • Browser flags and unblock extensions. --disable-web-security disables the same-origin policy for every site in that profile, including your banking tab. As a quick local experiment to confirm a diagnosis, okay. As a fix you ship to teammates, no.
  • Public CORS proxies. Routing your API traffic (with tokens) through a stranger’s server to avoid a header change on your own is a trade nobody should make. Run your own proxy if you must; it’s ten lines.

Cookies, credentials, and why * stops working

The rules change once requests carry credentials (cookies, or an Authorization header set by the browser). Then Access-Control-Allow-Origin: * is rejected outright; the server must echo the exact origin and add Access-Control-Allow-Credentials: true. And fetch doesn’t even send cookies cross-origin unless you pass credentials: 'include'. The number of “CORS is fine but the session is missing” bugs that come down to this default is large.

The restriction is the whole security story in one line: * plus credentials would mean any website can read any logged-in user’s data from your API. That’s the attack CORS exists to prevent. Related reading: our guide on SameSite cookie attributes, which is the other half of how browsers decide what cookies travel cross-site, and where to store JWTs, since the localStorage-vs-cookie decision changes which CORS rules you’re even playing by.

The CORS error that comes and goes

Intermittent CORS errors are almost never CORS. The usual suspects, in order of likelihood:

  1. Only on failures. The API’s error path (500, 401, rate-limit 429) skips CORS headers, so only failing responses get blocked. The console blames CORS; the real bug is whatever caused the 500. See the nginx always trap above; API gateways and Lambda error responses do the same.
  2. Only for some users. A cache without Vary: Origin is serving responses with someone else’s origin in the header.
  3. Only the first request in a while. Preflight cache expired (remember: 5-second default), and the OPTIONS endpoint is flaky or rate-limited.
  4. Only in one environment. Staging redirects http→https or strips trailing slashes, killing preflights; prod doesn’t. Or the allowlist simply doesn’t contain the staging origin.

Making it go away in development

For local development against a remote or containerised API, the clean solution is not CORS at all: make the requests same-origin by proxying them through your dev server. Vite’s server.proxy, webpack-dev-server’s proxy, or the proxy field in a CRA package.json all forward /api to the backend, and the browser only ever talks to one origin. No headers, no preflights, nothing to disable. In production the same trick is often right, too: serve frontend and API behind one domain and CORS stops being your problem entirely.

One more thing worth knowing for the local-network case: Chrome has been rolling out extra checks (Private Network Access) for public pages requesting private addresses like 192.168.x.x or localhost, with their own preflight headers on top of regular CORS. If a request from a deployed site to a device on your LAN fails in Chrome but works in Firefox, that’s the rabbit hole to check, not your header config.

CORS questions from the console

How do I fix a CORS error?

On the server, by sending an Access-Control-Allow-Origin response header that matches the origin of the page making the request. There is nothing you can change in your frontend JavaScript that fixes CORS for a server you control; the browser only trusts the header coming back in the response. If the request is preflighted, the server also has to answer the OPTIONS request with the matching Access-Control-Allow-Methods and Access-Control-Allow-Headers.

Why does my API work in Postman and curl but not in the browser?

Because CORS is enforced by browsers only. Postman, curl, server-to-server calls and mobile apps do not send a same-origin check and never see a CORS error. The browser blocks JavaScript from reading cross-origin responses unless the server opts in via headers; every other client just reads the response. That is also why testing an API with curl proves nothing about whether a web app can call it.

What is a preflight request?

A preflight is an automatic OPTIONS request the browser sends before the real request, asking the server for permission. It happens when the request is not "simple": any method beyond GET, HEAD and POST, any custom header like Authorization, or a Content-Type such as application/json. The server must answer with 2xx and the matching Access-Control-Allow-* headers, otherwise the real request is never sent at all.

Why does GET work but POST with JSON fail?

Because Content-Type: application/json makes the request non-simple and forces a preflight. Only form-urlencoded, multipart/form-data and text/plain count as simple content types, a list inherited from what HTML forms could already send before CORS existed. If your server (or the proxy in front of it) does not answer OPTIONS requests properly, exactly this pattern appears: plain GETs pass, JSON POSTs die in preflight.

Is Access-Control-Allow-Origin: * insecure?

For a public, unauthenticated API it is fine and common; the data is readable by anyone anyway. It becomes a problem the moment responses depend on who is asking: browsers refuse to combine * with credentialed requests precisely because that combination would let any website read a logged-in user’s data. If your API uses cookies or authorization headers, you must echo the specific origin instead, and validate it against an allowlist rather than reflecting anything blindly.

Why do I only get CORS errors when the API returns a 500?

Because the error path of your server skips the code that sets the CORS headers. Middleware-based setups do this a lot: an unhandled exception returns a bare 500 without Access-Control-Allow-Origin, the browser blocks it, and the console shows a CORS error instead of the real one. Nginx has the same trap, since add_header only applies to success responses unless you append the "always" keyword.

Does CORS protect against CSRF?

No. Simple requests, including form POSTs, are still sent to the server and executed there; CORS only stops the response from being read. A state-changing endpoint can be hit cross-origin all day long. CSRF protection needs its own mechanisms: SameSite cookies, CSRF tokens, or checking the Origin header server-side. CORS and CSRF get mixed up constantly, but they solve different problems.

Can I just disable CORS in my browser?

You can (chrome --disable-web-security, or one of the unblock extensions), and you shouldn’t, at least not in the profile you use for anything real. Disabling web security turns off the same-origin policy for every site you visit, which is the one thing standing between a malicious page and your logged-in sessions. For development, a dev-server proxy solves the same problem without making your browser an open door.