Skip to content
Frontend

CORS Error Explained: Read the Console Message, Then Fix the Server

A CORS error means the browser kept a cross-origin response from your script because the server did not opt in, either on that response or on the preflight that had to pass first. Your frontend code sees almost nothing; the console message names the exact check that failed. This guide reproduces the common Chrome messages with a page on localhost:5173 calling an API on localhost:4000, shows a working preflight with a small Node server and curl, and maps each message to its server-side fix.

By Published

9 min readEditorial analysisUpdated
  • CORS
  • browser security
  • HTTP
  • Node.js
  • frontend
What to remember

CORS is the server telling browsers which origins may read its responses. Read the console message, find the header or status that the preflight or response lacked, and fix it on the server or with a dev proxy. Never treat CORS as API security, and never ship a workaround that only changes your own browser.

What the browser enforces, and what it does not

An origin is the scheme, host, and port together, so http://localhost:5173 and http://localhost:4000 are different origins, and so are localhost and 127.0.0.1. Under the same-origin policy, a script may send many cross-origin requests but may not read their responses. CORS, defined in the Fetch Standard, is the protocol that lets a server relax that rule for chosen origins with Access-Control-* response headers. The browser enforces it on behalf of the user; curl, Postman, and server-to-server calls ignore it entirely.

That has a consequence people miss: CORS is not access control on your API. A simple request, defined below, is sent without a preflight, and the Fetch Standard applies the CORS check only when its response arrives. When the test page sent a cross-origin POST with Content-Type text/plain to an endpoint without CORS headers, the Node server logged POST /cards, and the script received only TypeError: Failed to fetch. The handler ran; only the response was hidden. A PUT with a JSON body behaved differently: the server saw only the failing OPTIONS preflight, so the PUT itself was never sent. Authentication and CSRF defenses belong on the server.

Read the exact console message

Fetch errors give JavaScript no detail on purpose; MDN notes that the specifics are visible only in the browser console. Each failure below was reproduced in Playwright's Chromium 156 build, with the page on localhost:5173 and a deliberately misconfigured API on localhost:4000. Every message began with Access to fetch at 'http://localhost:4000/...' from origin 'http://localhost:5173' has been blocked by CORS policy, followed by the specific reason in the table. These are Chromium's strings and may change between versions; Firefox and Safari word their reasons differently, and MDN's CORS errors page lists the Firefox variants.

One more message is worth memorizing: The 'Access-Control-Allow-Origin' header has a value 'http://localhost:3000' that is not equal to the supplied origin. It usually means a hard-coded origin from another environment, or a cache that served a response computed for a different origin because Vary: Origin was missing. A preflight answered with a redirect fails with yet another reason: Redirect is not allowed for a preflight request. Notice that every fix in the table changes the server's response, not the fetch call.

Chrome CORS messages and their fixes
Message containsLikely causeServer-side fix
No 'Access-Control-Allow-Origin' header is presentResponse, often an error page, lacks itSend it for allowed origins, errors too
Preflight: No 'Access-Control-Allow-Origin' headerRouter returns 404 or 405 to OPTIONSHandle OPTIONS with CORS headers
Preflight: does not have HTTP ok statusAuth or an error status answers OPTIONSAnswer OPTIONS with 204 before auth
must not be the wildcard '*' when … credentials mode is 'include'Wildcard origin with cookiesEcho the allowed origin, allow credentials
Request header field authorization is not allowedHeader missing from the allow listAdd it to Access-Control-Allow-Headers
Method DELETE is not allowedMethod missing from the allow listAdd it to Access-Control-Allow-Methods

Simple versus preflighted requests

A request is simple when it uses GET, HEAD, or POST, sets only CORS-safelisted headers, and, if it has a Content-Type, uses application/x-www-form-urlencoded, multipart/form-data, or text/plain. The browser sends it directly and checks Access-Control-Allow-Origin on the response. Anything else gets a preflight: an OPTIONS request carrying Access-Control-Request-Method and Access-Control-Request-Headers, which must come back with a 2xx status and matching Allow headers before the real request leaves the browser.

This is why a React or Vue app often meets CORS on its first API call. A fetch with Content-Type: application/json, an Authorization header, or a PUT, PATCH, or DELETE method is preflighted, so a backend whose router only knows GET and POST routes answers OPTIONS with 404 or 405. Access-Control-Max-Age lets the browser cache a successful preflight; MDN gives a default of five seconds when the header is absent and notes that browsers cap large values.

  • Triggers a preflight: a JSON Content-Type, Authorization or custom headers, any method other than GET, HEAD, or POST.
  • Does not: a plain GET, or a form-encoded POST without custom headers.
  • A passing preflight needs a 2xx status, Allow-Origin, and Allow-Methods and Allow-Headers that cover the real request.

A minimal Node server that answers the preflight

This server uses only node:http. It keeps an allow-list instead of reflecting whatever Origin arrives, sets Vary: Origin because the response differs per origin, and answers OPTIONS before any authentication would run. A disallowed origin still gets a 204, just without CORS headers, which the browser treats as a refusal. Start it with node api.mjs, serve your page from localhost:5173, and the request below succeeds.

Calling fetch('http://localhost:4000/cards/42', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }) from the page resolved with {"id":42,"saved":true}, and the server logged PUT /cards/42 from http://localhost:5173. The preflight never reached the logging line because it returns early, and the browser sent the PUT only after that preflight passed. Framework CORS middleware does the same job; this version exists so you can see every header it sets.

import { createServer } from "node:http";

const ALLOWED_ORIGINS = new Set(["http://localhost:5173"]);

createServer((req, res) => {
  const origin = req.headers.origin;
  const allowed = ALLOWED_ORIGINS.has(origin);
  res.setHeader("Vary", "Origin");
  if (allowed) res.setHeader("Access-Control-Allow-Origin", origin);

  if (req.method === "OPTIONS") { // preflight: answer it before any auth check
    if (allowed) {
      res.setHeader("Access-Control-Allow-Methods", "GET, PUT, DELETE");
      res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
      res.setHeader("Access-Control-Max-Age", "600");
    }
    return res.writeHead(204).end();
  }
  console.log(req.method, req.url, "from", origin ?? "no Origin header");
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ id: 42, saved: req.method === "PUT" }));
}).listen(4000, () => console.log("API on http://localhost:4000"));

Watch the preflight with curl, then add credentials

curl does not enforce CORS, which makes it a clean way to see what the server would tell a browser. Send the OPTIONS request a browser would send, with Origin and the two Access-Control-Request headers, and print only the relevant response headers. The allowed origin receives all four CORS headers. The unknown origin receives a 204 with only Vary: Origin, and a browser would block the PUT with the preflight message from the table.

Cookies change the rules. With credentials: 'include', the response must carry Access-Control-Allow-Credentials: true and an explicit origin; the Fetch Standard rejects * as the allowed origin for credentialed requests, and MDN notes that * in the Allow-Headers and Allow-Methods lists is not treated as a wildcard either. Do not fix this by echoing any incoming Origin with credentials allowed: that lets every website read authenticated responses on behalf of your users.

$ curl -s -o /dev/null -D - -X OPTIONS http://localhost:4000/cards/42 \
    -H "Origin: http://localhost:5173" \
    -H "Access-Control-Request-Method: PUT" \
    -H "Access-Control-Request-Headers: content-type" | grep -iE "^(HTTP|access-control|vary)"
HTTP/1.1 204 No Content
Vary: Origin
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

$ curl -s -o /dev/null -D - -X OPTIONS http://localhost:4000/cards/42 \
    -H "Origin: http://localhost:3000" \
    -H "Access-Control-Request-Method: PUT" \
    -H "Access-Control-Request-Headers: content-type" | grep -iE "^(HTTP|access-control|vary)"
HTTP/1.1 204 No Content
Vary: Origin

Fix it on the server or with a dev proxy, not an extension

In development, a proxy often removes the problem. Vite's server.proxy can forward /api from localhost:5173 to localhost:4000, so the browser only ever talks to its own origin and no CORS check applies. Vite documents these server options as dev-only, so production needs its own answer: serve the frontend and API from one origin behind a reverse proxy, or configure CORS on the API with an explicit origin list per environment.

Browser extensions that disable CORS, or launching Chrome with web security turned off, change only your own browser. Your users still see the error, and you have weakened a protection while browsing other sites with real sessions. mode: 'no-cors' is not a fix either: it yields an opaque response your code cannot read. If a third-party API you do not control lacks CORS headers, call it from your own backend.

Explain it in an interview, then practice

A compact interview answer: the same-origin policy blocks reading cross-origin responses; CORS lets a server opt in with headers; non-simple requests are preflighted with OPTIONS; credentials forbid wildcards; and CORS protects users' data in browsers rather than protecting your API from callers. Then debug one message aloud: read it, name the missing header, and say where you would add it.

Rebuild the Node server from memory, then break it on purpose: remove the OPTIONS branch, return 401 before it, or answer with Access-Control-Allow-Origin: * and call it with credentials: 'include', and predict the console message each time before checking. Then load the page from 127.0.0.1:5173 instead of localhost:5173 and explain why the allow-list rejects it. The articles below cover the surrounding HTTP and browser material.

Quick answers

Frequently asked questions

Why does the request work in Postman or curl but not in the browser?

Postman and curl do not implement the same-origin policy, so they show you whatever the server returns. The browser enforces CORS for scripts running on a page, because that script runs with the user's cookies and network position. Same request, different client rules.

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

Not for genuinely public, non-credentialed data such as a public JSON feed, because browsers will not combine it with cookies. The danger lies elsewhere: reflecting any Origin while allowing credentials, or exposing internal data with * on the assumption that only your own frontend will call it.

Why do I see an OPTIONS request in the Network tab?

That is the preflight. The browser sends it before a non-simple request to ask which origin, methods, and headers the server accepts. If it fails, the real request is never sent. A successful preflight can be cached for the Access-Control-Max-Age the server returns.

Does CORS protect my API from other websites?

Only partly. It stops scripts on other origins from reading responses in a browser, but a simple cross-origin request can still reach your server and change data. Use authentication, CSRF tokens or SameSite cookies, and authorization checks on the server; CORS replaces none of them.

Source notes

References and review policy

Information checked on November 7, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going