Backend & APIs

CORS Errors: How to Read the Message and Fix the Right Thing

Your fetch() works in curl. It works in Postman. It works when you paste the URL into the address bar. In the browser, from your app, it fails with a red console message about "CORS policy" — and the response body you can plainly see in the Network tab never reaches your code.

The takeaway up front: a CORS error is not a network failure and not a server error. It is the browser refusing to hand your JavaScript a response that already arrived. The request left, the server processed it, the response came back — and then the browser checked whether the server gave permission for this origin to read it, found no such permission, and threw the result away. That means two things. Every real fix lives on the server that owns the API, and no amount of changing your fetch options will conjure a permission the server didn't grant.

This guide is a diagnostic path: confirm it's CORS, read which of the four failure modes you're in, apply that fix, and skip the popular non-fixes.

Step 1: Confirm it is actually CORS

Two very different problems produce a red line in the console, and they need opposite responses.

Open DevTools → Network, find the failing request, and look at the response:

  • Status code present, headers present, body present, but your .then() never got it → CORS. The server answered; the browser censored it.
  • Status (failed), no response, no headers at all → not CORS. That's DNS, TLS, a connection refusal, a firewall, or the server being down. Chasing Access-Control-Allow-Origin here wastes an afternoon.

One nuance that fools people: a preflighted request (see below) fails at the OPTIONS stage, so the real request may never appear at all. If you see an OPTIONS row with a 4xx/5xx status and no follow-up POST, you're in CORS territory, at the preflight stage specifically.

Also note the shape of the JavaScript error. A blocked cross-origin response rejects with a deliberately vague TypeError: Failed to fetch — the browser withholds detail so a page can't use error messages to probe internal networks. The useful message is the one printed separately to the console, and that's the string you diagnose from.

Step 2: Match the message to the cause

Four messages cover nearly every case. Read yours carefully — they describe different problems with almost identical opening words.

Console message contains What it means Where the fix goes
No 'Access-Control-Allow-Origin' header is present The server never opted in to cross-origin reads at all API server response headers
Response to preflight request doesn't pass access control check / does not have HTTP ok status The OPTIONS preflight was rejected, 404'd, or hit auth API server routing — handle OPTIONS
Request header field <name> is not allowed by Access-Control-Allow-Headers Preflight ran, but your custom header wasn't on the allow-list Add the header to Access-Control-Allow-Headers
The value of 'Access-Control-Allow-Origin' must not be the wildcard '*' when the request's credentials mode is 'include' Cookies/credentials require an exact origin echo, not * Echo the specific origin + allow credentials

Step 3: Understand simple vs preflighted requests

Half of all CORS confusion comes from not knowing why an OPTIONS request appeared out of nowhere.

A simple request goes straight out. It qualifies if it's GET, HEAD, or POST, uses only a short list of safe headers, and its Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. The browser sends it, then checks the response headers before releasing it to your code.

Anything else is preflighted. Before the real request, the browser sends an OPTIONS request asking permission:

OPTIONS /v1/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

And it expects an affirmative, 2xx answer:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

This is why Content-Type: application/json — utterly routine on a JSON API — is the single most common trigger for a preflight, and why an endpoint that "worked yesterday" breaks the moment you add an Authorization header. You didn't break CORS; you crossed the line from simple to preflighted.

The trap is that many frameworks and routers only register the handlers you declared. If you defined POST /v1/orders and nothing else, the OPTIONS probe hits your 404 handler, returns a non-2xx, and the preflight fails — with a message that says nothing about routing. Worse, auth middleware mounted globally will happily reject the OPTIONS request with a 401, because preflights deliberately carry no credentials.

Step 4: Apply the server-side fix

Put the headers on the API's responses. Conceptually you need four things; the syntax varies by stack.

// Express — mount before your routes and before auth middleware
const ALLOWED = new Set(["https://app.example.com", "http://localhost:5173"]);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");           // don't let a CDN cache one origin's answer for all
  }
  res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id");
  res.setHeader("Access-Control-Max-Age", "86400");

  if (req.method === "OPTIONS") return res.sendStatus(204);  // answer the preflight, skip auth
  next();
});

Four details in that block do the real work:

Echo the origin; don't hardcode * for anything private. Maintain an allow-list and reflect the matching value back. * is fine for a genuinely public, credential-free API and wrong for everything else — and it's outright rejected when credentials are involved.

Always send Vary: Origin when the header is dynamic. Without it, a caching layer can store the response computed for one origin and serve it to another, producing failures that appear and vanish depending on cache state.

Short-circuit OPTIONS before authentication. Preflights carry no cookies or Authorization header by design, so any auth check will reject them.

List every custom header you send. Access-Control-Allow-Headers is an exact allow-list. Add X-Request-Id on the client and you must add it here too.

For cookie-based sessions, the client opts in and the server must respond with both an exact origin and the credentials flag:

await fetch("https://api.example.com/v1/me", { credentials: "include" });
Access-Control-Allow-Origin: https://app.example.com   // never '*' here
Access-Control-Allow-Credentials: true

And if your JavaScript needs to read a response header — a pagination cursor, a rate-limit counter — the server must expose it explicitly, because by default only a small safe set is readable:

Access-Control-Expose-Headers: X-Total-Count, X-RateLimit-Remaining

If you're designing the API rather than patching it, decide these rules once and document them alongside your status codes and error shapes, as covered in the REST API design guide.

Step 5: Skip the anti-fixes

mode: 'no-cors' is the most seductive wrong turn. It doesn't grant access — it downgrades the request to an opaque response whose status reads 0 and whose body is unreadable. Your fetch stops throwing and starts silently returning nothing, which converts a loud, diagnosable error into a quiet bug. If your await resolves to something empty rather than throwing, check for this first; it interacts badly with the error handling patterns in the async/await guide.

Launching the browser with web security disabled proves nothing and fixes nothing for your users.

Adding CORS headers to the front-end response. The headers must come from the server being called. Setting them on the page's own origin has no effect at all.

Adding them twice. A gateway, load balancer, or CDN that injects Access-Control-Allow-Origin while your app also sets it produces a duplicated header, which browsers reject outright. Pick exactly one layer to own CORS.

When a proxy is the correct answer

Sometimes you genuinely cannot change the server — a third-party API with no CORS support, or one whose token must never touch the browser. Then the right move is a same-origin server-side proxy: your front end calls your own backend at /api/thing, and your backend calls the third party.

This works because CORS is a browser policy, not an internet law. Server-to-server requests never consult it. It also keeps API keys off the client, where they'd be readable by anyone with DevTools. The same reasoning explains why fetching during server-side rendering never trips CORS while the identical fetch in the browser does — a distinction worth understanding when choosing a rendering strategy.

The trade-off is real: an extra hop, an extra service to run, and a proxy that becomes an open relay if you don't restrict which upstream paths it will forward. Allow-list the routes it can reach.

FAQ

Why does my API work in Postman but fail with CORS in the browser?

Postman is not a browser and doesn't enforce the same-origin policy, so it never checks for permission headers. The browser does. A request succeeding in Postman confirms the server is reachable and the endpoint works — it tells you nothing about whether the server has authorized your web origin to read the response.

Can I fix a CORS error from the front end?

No, other than by not making a cross-origin request in the first place. The permission is granted by response headers from the API server. If you don't control that server, your options are to route the call through a backend proxy you do control, or to get the API's owner to allow your origin.

What is a preflight request and why did one appear?

It's an automatic OPTIONS request the browser sends before any request that isn't "simple" — anything using PUT/DELETE/PATCH, custom headers, or a Content-Type like application/json. It asks the server whether the real request is permitted. If your framework has no OPTIONS handler for that path, or auth middleware rejects it, the preflight fails and the real request is never sent.

Is Access-Control-Allow-Origin: * safe to use?

Only for a public API that requires no credentials and returns nothing sensitive. It tells every website that its JavaScript may read your responses. It's also incompatible with credentialed requests — browsers reject the combination — so anything using cookies or session auth must echo a specific, allow-listed origin instead.

Why does my CORS setup work sometimes and fail other times?

Two usual culprits: a dynamic Access-Control-Allow-Origin sent without a Vary: Origin header, letting a cache serve one origin's response to another; or two layers (app plus gateway/CDN) both adding the header, which browsers reject as duplicated. Make one layer own CORS, and always send Vary: Origin when the value depends on the request.

Next step

Work the path in order: confirm a response actually arrived, read which of the four messages you have, and fix it on the server that owns the API — allow-list the origin, answer OPTIONS before auth, list your custom headers, and send Vary: Origin. If the server isn't yours, proxy through one that is. For task-level snippets on making requests from JavaScript, Swift, Kotlin, and Python — including the exact fetch and header setups referenced here — browse the networking how-tos at TheAppCode.

Comments are disabled for this article.