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/jsonon a POST- an
Authorizationheader, or any custom header such asX-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 says | What it means |
|---|---|
| No 'Access-Control-Allow-Origin' header is present on the requested resource | The 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 check | The 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-Headers | The preflight answered, but did not list a header you are sending. |
| Method PUT is not allowed by Access-Control-Allow-Methods | The 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 andAccess-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 coversAuthorization. List it by name. - Reading custom response headers. Your code can read only a few response headers by default, such as
Content-Type. To readX-Total-Countor a rate-limit header, the server must name it inAccess-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:5173andhttp://127.0.0.1:5173are different origins, and so arehttps://example.comandhttps://www.example.com.