CORS error messages explained (tested)

Every CORS error in Chrome starts with Access to fetch at '...' from origin '...' has been blocked by CORS policy:. The part after the colon tells you the cause. We broke an Express API in 16 different ways and recorded the exact message Chromium 153 printed for each one.

1. How we tested

One Express 5.2.1 server on port 8986 served a blank page and an API. The page was loaded from http://127.0.0.1:8986 and called the API at http://localhost:8986. Different host means different origin, so every call was cross-origin. Each API route sent a different set of CORS headers. A Playwright script ran fetch() in the page and collected messages with page.on('console').

Every failed case printed two console errors: the CORS message and Failed to load resource: net::ERR_FAILED. In JavaScript, every failed fetch() rejected with the same TypeError: Failed to fetch. The script cannot see which rule failed. Only the console can.

2. Every message, its cause and the fix

Each message below starts with Access to fetch at 'http://localhost:8986/api/...' from origin 'http://127.0.0.1:8986' has been blocked by CORS policy:. We show only the part after that.

What the server didExact console message (after the prefix)Fix (Express)
No Access-Control-Allow-Origin at allNo 'Access-Control-Allow-Origin' header is present on the requested resource.res.set('Access-Control-Allow-Origin', 'https://app.example.com')
ACAO set to a different origin (https://example.com)The 'Access-Control-Allow-Origin' header has a value 'https://example.com' that is not equal to the supplied origin. Have the server send the header with a valid value.Send the caller's exact origin: scheme, host and port must all match.
ACAO with two origins, comma-separatedThe 'Access-Control-Allow-Origin' header contains multiple values 'http://127.0.0.1:8986, https://example.com', but only one is allowed. Have the server send the header with a valid value.Check req.headers.origin against an allow-list and echo back one value.
Two separate ACAO headers (res.append twice)Same "contains multiple values" message as above.Set the header once. Look for a proxy that adds a second copy.
ACAO *, fetch with credentials: 'include'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'.Echo the exact origin instead of *, and add Vary: Origin.
Correct ACAO, credentials included, no Access-Control-Allow-CredentialsThe value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.res.set('Access-Control-Allow-Credentials', 'true')
Access-Control-Allow-Credentials: TRUEThe value of the 'Access-Control-Allow-Credentials' header in the response is 'TRUE' which must be 'true' when the request's credentials mode is 'include'.Lowercase true only.
Preflight (OPTIONS) returns 404 with no CORS headersResponse to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.Answer OPTIONS with 204 and the CORS headers (see section 4).
Preflight returns 404 but with ACAOResponse to preflight request doesn't pass access control check: It does not have HTTP ok status.Return 200 or 204 to OPTIONS.
Preflight returns 500 with ACAOResponse to preflight request doesn't pass access control check: It does not have HTTP ok status.Same. Check auth middleware is not rejecting OPTIONS.
Custom header X-Api-Key, preflight has no Access-Control-Allow-HeadersRequest header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response.Access-Control-Allow-Headers: X-Api-Key
POST with Content-Type: application/json, no Allow-HeadersRequest header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.Access-Control-Allow-Headers: Content-Type
PUT, preflight allows only GET, POSTMethod PUT is not allowed by Access-Control-Allow-Methods in preflight response.Access-Control-Allow-Methods: GET, POST, PUT, DELETE
DELETE, preflight allows only GET, POSTMethod DELETE is not allowed by Access-Control-Allow-Methods in preflight response.Same as above.
Preflight answered with a 301 redirectResponse to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.Call the final URL directly (trailing slash, http to https, www).
Preflight OK, but the real PUT returns 500 without ACAO (error handler skipped CORS)No 'Access-Control-Allow-Origin' header is present on the requested resource.Set CORS headers before any route or error handler runs.

Note the last row. The server really did return 500, but the console only says the header is missing. If you see "No 'Access-Control-Allow-Origin'" on an endpoint that usually works, check the server log for an error first. See 500 Internal Server Error.

3. The request often reached your server

CORS is enforced by the browser, not the server. Our server log shows that for every simple GET (the first 7 rows), the handler ran and returned 200. The browser received the body and then refused to hand it to the page.

For requests that need a preflight (PUT, DELETE, custom headers, JSON body), only the OPTIONS request arrived when the preflight failed. The real request was never sent.

GET none origin=http://127.0.0.1:8986
OPTIONS put origin=http://127.0.0.1:8986 acrm=PUT
OPTIONS json origin=http://127.0.0.1:8986 acrm=POST acrh=content-type
OPTIONS err500 origin=http://127.0.0.1:8986 acrm=PUT
PUT err500 origin=http://127.0.0.1:8986

curl and Node.js never show CORS errors, even with a fake Origin header. Against the route with no CORS headers at all:

$ curl -sS -i -H 'Origin: https://evil.example' http://localhost:8986/api/none
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
...
ok-none

$ node -e "fetch('http://localhost:8986/api/none',{headers:{Origin:'https://evil.example'}})
  .then(async r=>console.log(r.status, r.headers.get('access-control-allow-origin'), await r.text()))"
200 null ok-none

So "it works in curl / Postman" proves nothing about CORS. And CORS is not access control: anyone outside a browser can still call your API.

4. A preflight trap in Express

If you set the CORS header only inside app.put(), Express answers OPTIONS on its own. We measured it: status 200, Allow: PUT, body PUT, and no CORS headers. Chromium then reports "Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource." Put CORS in middleware that runs before the routes.

5. A working Express setup

This middleware passed our hardest test: a PUT with credentials: 'include', Content-Type: application/json and X-Api-Key. Chromium sent one OPTIONS and one PUT, and the fetch returned 200.

const ALLOWED = new Set(['https://app.example.com', 'http://localhost:5173']);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (ALLOWED.has(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Access-Control-Allow-Credentials', 'true');
  }
  res.vary('Origin');
  if (req.method === 'OPTIONS') {
    res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.set('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key');
    res.set('Access-Control-Max-Age', '600');
    return res.sendStatus(204);
  }
  next();
});

For the allow-list pattern in more servers, read CORS: allow multiple origins. Why Vary: Origin matters for caches is in Vary: Origin and CORS caching. To cut repeat preflights, see Access-Control-Max-Age.

Header deep dives: Access-Control-Allow-Credentials, Access-Control-Allow-Headers, Access-Control-Allow-Methods, the Origin header.

6. Debug checklist

Frequently asked questions

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

CORS is only enforced by browsers. In our test, curl and Node.js fetch got 200 and the full body from a route with no CORS headers at all. Chromium got the same 200 but blocked the page from reading it.

Does a CORS error mean my server never got the request?

Not always. For simple GET requests our server ran the handler and returned 200 every time; the browser hid the response. For PUT, DELETE, JSON bodies or custom headers, a failed preflight means only the OPTIONS request arrived.

Can I see which CORS rule failed from JavaScript?

No. Every failure we tested rejected with the same TypeError: Failed to fetch. The specific reason is only in the browser console.

Why does Content-Type: application/json cause a CORS error?

application/json is not one of the content types allowed without a preflight. Chromium sent an OPTIONS request and reported 'Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response' until we added Access-Control-Allow-Headers: Content-Type.

Can I use Access-Control-Allow-Origin: * with cookies?

No. With credentials: 'include', Chromium 153 rejected * and also required Access-Control-Allow-Credentials: true in lowercase. Echo the exact allowed origin and add Vary: Origin.

Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright, page.on('console')), Express 5.2.1 on Node.js 24.21.0, and curl 8.14.1. The page ran at http://127.0.0.1:8986 and the API at http://localhost:8986, which are two different origins. PlainHeaders is our extension; Chromium, Express, Node.js and curl are free.