CORS

Understand what the browser is actually refusing, and configure the server so it stops.

09of 18
≈ 14Min read
5Parts

The rule underneath it

A page may send a request anywhere — that is how an image or a form has always worked. What it may not do is read the response from another origin, unless that origin says it may. Without the rule, any page you visit could read your webmail using your cookies.

So a CORS error is never a bug in your JavaScript, and the request was usually not blocked: the server answered, and the browser refused to hand the answer to the page. That is why the network panel shows a perfectly good 200 next to a red console message.

It is a browser rule, not a security boundary on the server

curl, a mobile app and any server ignore CORS entirely. It protects your users from other websites; it does not protect your API. Authorisation still has to be checked on every request.

Simple requests, and the preflight

Anything else — a PUT, a DELETE, Content-Type: application/json, an Authorization or custom header — triggers a preflight: an OPTIONS request the browser sends first, asking whether the real one is allowed. That is an extra round trip before every such call, which is why a chatty API on another origin feels slow.

what makes a request "simple" — no preflight needed
method GET, HEAD or POST
and only these headers:  Accept, Accept-Language, Content-Language, Content-Type, Range
and if Content-Type is set, one of:
  application/x-www-form-urlencoded
  multipart/form-data
  text/plain
the preflight exchange
OPTIONS /orders
Origin: https://app.doors.test
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.doors.test
Access-Control-Allow-Methods: PUT, DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 600        # cache this answer for ten minutes

The headers a server has to send

HeaderSays
Access-Control-Allow-Originwho may read the response — one origin, or *
Access-Control-Allow-Methodswhich methods the preflight permits
Access-Control-Allow-Headerswhich request headers are allowed
Access-Control-Allow-Credentials: truecookies and auth headers may be sent
Access-Control-Expose-Headerswhich response headers the page may read
Access-Control-Max-Agehow long a preflight answer may be cached
* and credentials are mutually exclusive

With credentials: "include", Access-Control-Allow-Origin: * is refused by every browser — you must echo the exact origin, and you must send Allow-Credentials: true. Echoing means comparing the Origin header against a list you wrote. Reflecting it blindly is the same as having no rule at all.

What the page can read

Only a small safelist (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma) is visible by default. Pagination headers are the usual casualty: the data arrives, the total count does not, and nothing in the console explains why.

a cross-origin response hides most of its headers
const res = await fetch("https://api.doors.test/orders", { credentials: "include" });

res.headers.get("content-type");     // allowed — it is safelisted
res.headers.get("x-total-count");    // null, unless the server exposed it

// on the server
Access-Control-Expose-Headers: X-Total-Count

The four errors, and what they mean

  • "No 'Access-Control-Allow-Origin' header" — the server sent none. Often because the error path (a 500, or the framework's 404) does not go through the CORS middleware.
  • "…does not match" — you are on http://localhost:3000 and the list says http://localhost:5173. Origins include the port.
  • "Response to preflight request doesn't pass" — the OPTIONS was not handled, or did not allow the method or a header. Check that your router answers OPTIONS at all.
  • *"…the value '' when the request's credentials mode is 'include'"** — exactly what it says; echo the origin instead.

Practice