CORS error on fetch: what causes it and how to fix it

Published · 6 min read

A CORS error means the browser refused to give your JavaScript the response. The server usually answered. Your code is not allowed to read the answer, because the server did not say that pages from your origin may read it.

So the fix is never in the fetch call. Either the API sends the right headers, or the browser does not call the API directly.

What the browser is doing

An origin is the scheme, host and port of a page: https://app.example.com and http://localhost:5173 are two different origins. When a page calls an API on another origin, the browser adds an Origin header to the request and checks the response for Access-Control-Allow-Origin. If that header is missing, or names a different origin, the browser throws the response away and your fetch rejects.

That is why the same call works in curl, Postman or a server: CORS is a rule that only browsers enforce, to protect the person using the page. It is also why the error can’t be fixed from the page. If a page could switch the check off, it would protect no one.

Preflight: when the request never leaves

Some requests are sent first and checked after. Others are checked before they are sent, with a preflight: an OPTIONS request that asks the server whether the real request is allowed.

A request is sent without a preflight only if it uses GET, HEAD or POST, sets no headers beyond a short safe list, and its Content-Type, if any, is text/plain, application/x-www-form-urlencoded or multipart/form-data. Anything else is preflighted. In practice that means:

  • Content-Type: application/json on a POST
  • an Authorization header, or any custom header such as X-Api-Key
  • PUT, PATCH or DELETE

This explains the common “GET works, POST doesn’t”. The GET is a simple request; the JSON POST needs a preflight, and the server has no answer for OPTIONS.

It also matters for side effects. A simple POST reaches the server and runs, even though your code can’t read the response. A preflighted request that fails the check is never sent at all.

The messages, and what each one means

Chrome prints the reason after “Access to fetch at ‘…’ from origin ‘…’ has been blocked by CORS policy:”. Firefox starts with “Cross-Origin Request Blocked” and gives the reason in brackets, followed by the real status code. Safari says “Fetch API cannot load … due to access control checks.” In your code, all of them surface as a bare TypeError (“Failed to fetch”, “NetworkError when attempting to fetch resource” or “Load failed”), with no status and no details, on purpose.

The console saysWhat it means
No 'Access-Control-Allow-Origin' header is present on the requested resourceThe response has no CORS headers at all. Either the API does not support browser calls, or this response came from a path that skips them, such as an error.
Response to preflight request doesn’t pass access control checkThe OPTIONS request failed. The rest of the message says why: no allow-origin header, or “It does not have HTTP ok status” when the server answered it with a 404, 401 or 405.
Request header field authorization is not allowed by Access-Control-Allow-HeadersThe preflight answered, but did not list a header you are sending.
Method PUT is not allowed by Access-Control-Allow-MethodsThe same, for the method.
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request’s credentials mode is 'include'You send cookies, and the server answers with *. With credentials, it must name your origin exactly.

Why mode: ‘no-cors’ does not help

Chrome’s message suggests setting mode: 'no-cors'. That does not lift the rule. It tells the browser you accept an opaque response: status 0, no headers, an empty body. The request goes out and your code gets nothing back.

It also changes what you send. In no-cors mode only GET, HEAD and POST are allowed, and any header outside the safe list is silently dropped, so your Authorization header and your application/json content type never reach the server. It is meant for things like firing a beacon whose answer you don’t need.

The fix, by case

You own the API

Have the server send the headers, on every response, and answer the preflight. Here is a complete Node server with no framework, so every header is visible:

import { createServer } from 'node:http';

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

createServer((req, res) => {
  const origin = req.headers.origin;
  if (origin !== undefined && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    // Only if the page sends cookies, with credentials: 'include'.
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  // The answer depends on Origin, so caches must not share it between origins.
  res.setHeader('Vary', 'Origin');

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Max-Age', '600');
    res.writeHead(204).end();
    return;
  }

  res.writeHead(200, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ ok: true }));
}).listen(8080);

The header allows one origin, or *. To allow several, check the request’s Origin against a list and echo it back, as above. With Express, the cors package does the same:

app.use(cors({ origin: ['https://app.example.com', 'http://localhost:5173'], credentials: true }));

Register it before anything that can answer a request, including authentication, so a 401 still carries the headers.

You don’t own the API

Call it from a server instead of the browser. Browsers apply CORS; servers don’t. Your page calls a route on its own origin, and that route calls the API:

// Express, on the same origin as the page.
app.get('/api/items', async (req, res) => {
  const upstream = await fetch('https://api.example.com/v1/items');
  res.status(upstream.status).type('application/json').send(await upstream.text());
});

This is also where an API key belongs, since a key in browser code is readable by anyone (how to hide an API key). Keep the upstream URL fixed in the route. A route that fetches whatever URL the browser sends is an open proxy, and a way into your private network (SSRF protection for a fetch endpoint).

Public CORS proxies do the same job without a server of your own, but every request and every key you send passes through someone else’s machine. Fine for a demo with public data, not for anything with credentials.

Only in development

If the API will be on the same origin in production, and only your dev server is on a different port, let the dev server forward the calls. In Vite:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': 'http://localhost:8080',
    },
  },
};

The page calls /api/items, which is same-origin, and Vite passes it on. This does nothing for a production build.

Pitfalls

  • Error responses without headers. If your CORS code runs after authentication, routing or an error handler, a 401, 404 or 500 goes out without Access-Control-Allow-Origin. The browser reports a CORS error and hides the real status. Look at the server log or the Network tab before you change any CORS settings.
  • Wildcard with credentials. credentials: 'include' needs an exact origin and Access-Control-Allow-Credentials: true. With credentials, * in the allow-headers and allow-methods lists is not a wildcard either.
  • Authorization and the wildcard. Access-Control-Allow-Headers: * never covers Authorization. List it by name.
  • Reading custom response headers. Your code can read only a few response headers by default, such as Content-Type. To read X-Total-Count or a rate-limit header, the server must name it in Access-Control-Expose-Headers.
  • Redirects. If the API redirects, every response on the way needs the headers, and a preflight that gets a redirect instead of a 2xx answer fails. Call the final URL.
  • Origin mismatch you can’t see. http://localhost:5173 and http://127.0.0.1:5173 are different origins, and so are https://example.com and https://www.example.com.

Keep reading

How to hide an API key in frontend JavaScriptYou can’t. Anything the browser can read, a visitor can read. Here is what to do instead, with the trade-offs of each option.SSRF protection for a fetch endpoint in Node.jsPrivate ranges, DNS rebinding, redirects and IPv6, one at a time.dash.fetchReference for dash.fetch: its options, the response object, how to add a secret with a placeholder, the limits and every error code.