Understanding CORS errors and how to fix them

8 min read
http
cors
guide
Try CORS Config GeneratorFree, runs in your browser — no upload or sign-up.Open tool →

CORS errors are one of the few browser errors that show up only in the console and never in your server logs, which is exactly why they confuse people: the request often succeeds on the server and fails only in the browser's eyes.

What CORS actually is

Cross-Origin Resource Sharing is a browser-enforced security policy, not a server-enforced one. When JavaScript on https://app.example.com tries to fetch https://api.example.org, the browser checks whether the response includes headers that explicitly allow app.example.com to read it. If those headers are missing or wrong, the browser throws the response away — but the server already processed the request. That's why you sometimes see a database row get created even though the client saw an error: the write succeeded, only the response was blocked from JavaScript.

The same-origin policy this protects against is old and strict: scheme, host and port all have to match exactly. http://example.com and https://example.com are different origins. example.com and api.example.com are different origins. localhost:3000 and localhost:5173 are different origins — this one bites almost everyone during local development.

Simple requests vs preflighted requests

A "simple" request (GET/POST/HEAD with only a few safe headers and a body type of form-encoded, multipart or plain text) goes straight to the server, and the browser inspects the response headers.

Anything else — a custom header like Authorization or X-Api-Key, a JSON body with Content-Type: application/json, or a method like PUT/DELETE/PATCH — triggers a preflight: the browser sends an OPTIONS request first, asking permission, before it sends the real one.

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

If your server doesn't answer OPTIONS requests (a common gap when routes are only registered for GET/POST), the preflight itself fails and the real request never leaves the browser.

The headers that actually matter

A server needs to answer both the preflight and the real response with headers matching what the browser asked for:

text
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

Access-Control-Allow-Origin can be *, but * cannot be combined with credentials (cookies or Authorization headers sent via credentials: "include"). If you need credentials, you must echo back the exact requesting origin and add:

text
Access-Control-Allow-Credentials: true

Note there is no wildcard option for credentialed requests — the server has to look at the incoming Origin header and reflect it (after checking it against an allow-list), not hardcode a single value, if you serve more than one frontend.

Reading the exact error message

Chrome and Firefox report specific reasons, and each maps to a specific fix:

  • "No 'Access-Control-Allow-Origin' header is present" — the server didn't send CORS headers at all, or sent them only on success and not on error responses (a common bug: a 500 error handler that skips the CORS middleware).
  • "The 'Access-Control-Allow-Origin' header contains multiple values" — usually two pieces of middleware (e.g. a CDN and the app) both add the header, resulting in *, https://app.example.com. Remove one.
  • "Method PUT is not allowed" — Access-Control-Allow-Methods didn't include the method used.
  • "Request header field authorization is not allowed" — Access-Control-Allow-Headers is missing that header name.
  • "has been blocked by CORS policy: Response to preflight request doesn't pass access control check" — the OPTIONS handler exists but isn't returning the right headers, or is returning a non-2xx status.

A minimal Express fix

js
app.use((req, res, next) => {
  const allowed = ["https://app.example.com", "http://localhost:5173"];
  const origin = req.headers.origin;
  if (allowed.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});

Put this before your routes, not after, so preflight requests short-circuit before hitting business logic.

What CORS is not for

CORS does not protect your API — it protects browser users. A server-to-server request, a mobile app, or curl never enforces CORS at all; only browsers do. If your API needs to be actually private, use authentication and authorization, not an origin allow-list — the allow-list only decides which websites are allowed to read a response inside a browser tab.

Debugging checklist

  1. Check whether the failing request is a preflight (OPTIONS) or the real request in the network tab.
  2. Confirm the response — including error responses — carries CORS headers.
  3. Confirm the allowed origin matches exactly, including scheme and port.
  4. If using credentials, confirm the origin is echoed, not *, and Allow-Credentials: true is present on both requests.
  5. Confirm every custom header and method you send is listed in the corresponding Allow-Headers/Allow-Methods.

Generating this header set by hand for every environment (dev, staging, multiple frontend domains) is exactly the kind of repetitive, error-prone task worth automating with a small config generator rather than copy-pasting from an old project.

Tools from this article

← All articles