CORS Errors Explained: Why Requests Get Blocked and How to Fix Preflight and Credentials Correctly
A 'has been blocked by CORS policy' error comes from the browser, not the server. The same-origin policy, simple vs preflighted requests, requests with cookies, and exposing response headers, verified in a real browser, plus why fixes like mode: 'no-cors' don't work.
The server is fine, so why the error?
Your frontend (http://localhost:5000) sends a request to your API server (http://localhost:5001), and the console shows:
Access to fetch at 'http://localhost:5001/users' from origin 'http://localhost:5000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header
is present on the requested resource.
It works in Postman and curl, just not in the browser. And surprisingly, the API server's log shows the request arrived and was fully handled.
[API] GET /users origin=http://localhost:5000 ← the server got the request and sent a response
A CORS error doesn't mean the server rejected the request; it means the browser refused to hand the response to your JavaScript, because the server didn't send a header saying "this origin may see the response."
That's why you can't fix CORS in frontend code. You fix it with the server's response headers.
Origins and the same-origin policy
By default, browsers stop JavaScript from reading responses from a different origin. This is the same-origin policy. Two URLs share an origin only when protocol + host + port all match.
| Compared with https://blog.com | Same origin? | Why |
|---|---|---|
| https://blog.com/posts | ✅ | Path doesn't matter |
| http://blog.com | ❌ | Different protocol |
| https://api.blog.com | ❌ | Different host (subdomains are different origins) |
| https://blog.com:8080 | ❌ | Different port |
Without the same-origin policy, JavaScript on a malicious site could call the API of the bank you're logged into and read the responses (your balance, your transactions). CORS (Cross-Origin Resource Sharing) is the set of rules that opens an exception only when the server allows it.
The basic fix: Access-Control-Allow-Origin
When the server includes this header, the browser hands over the response.
// Node.js built-in http module
import http from "node:http";
http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5000");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ users: [] }));
}).listen(5001);
With Express, the cors middleware does the same thing.
import cors from "cors";
app.use(cors({ origin: "https://my-frontend.com" }));
Simple requests vs preflight
With that in place, send JSON with POST and you're blocked again:
Request header field content-type is not allowed by Access-Control-Allow-Headers
in preflight response.
Browsers treat requests in two ways:
| Kind | Conditions | Browser behavior |
|---|---|---|
| Simple request | GET/HEAD/POST + only basic headers + Content-Type of text/plain, multipart/form-data, or application/x-www-form-urlencoded | Sends it right away, then checks the response's Allow-Origin |
| Everything else (preflighted) | PUT, DELETE, PATCH / Content-Type: application/json / custom headers like Authorization | Asks for permission first with an OPTIONS request, and sends the real request only if allowed |
application/json isn't one of the simple-request types, so most API requests get a preflight.
When you check, a server that doesn't answer preflight properly logs this:
[API] OPTIONS /posts origin=http://localhost:5000 ← only the permission check arrives
← the real POST is never sent
When preflight fails, the real request is never sent to the server. A simple request reaches the server and only its response is blocked; a preflighted request is stopped before it leaves.
The server has to answer OPTIONS with the methods and headers it allows.
http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5000");
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"); // cache the preflight result for 10 minutes
res.statusCode = 204;
res.end();
return;
}
// ...handle the real request
}).listen(5001);
| Preflight response header | Meaning |
|---|---|
| Access-Control-Allow-Methods | HTTP methods that are allowed |
| Access-Control-Allow-Headers | Headers the request may include |
| Access-Control-Max-Age | How many seconds to cache the preflight result (so OPTIONS isn't sent every time) |
Sending cookies: credentials
To send a login session cookie to an API on a different origin, both the frontend and the server need settings.
// Frontend
fetch("https://api.my-service.com/me", { credentials: "include" });
// Server
res.setHeader("Access-Control-Allow-Origin", "https://my-service.com"); // * not allowed
res.setHeader("Access-Control-Allow-Credentials", "true");
The most common mistake here is combining Allow-Origin: * with cookies. Browsers reject that combination:
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'.
Requests that send cookies can't use *; you must name one exact origin. If you allow several origins, compare the request's Origin header against an allowlist and echo it back only when it matches. Send Vary: Origin as well, so a CDN doesn't mix up cached responses meant for different origins.
const ALLOWED = ["https://my-service.com", "https://admin.my-service.com"];
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("Vary", "Origin");
When a response header reads as null: Expose-Headers
In CORS requests, only a few response headers (like Content-Type) are readable by default. If you send something like X-Total-Count for pagination and the frontend gets null, this is why.
// The server sent X-Total-Count: 42, but...
res.headers.get("X-Total-Count"); // null
The server has to list the headers it exposes.
res.setHeader("Access-Control-Expose-Headers", "X-Total-Count");
// now res.headers.get("X-Total-Count") → "42"
Fixes that don't actually fix anything
❌ mode: "no-cors"
People often see the error and change the code like this:
const res = await fetch("http://localhost:5001/users", { mode: "no-cors" });
res.type; // "opaque"
res.status; // 0
await res.json(); // fails: you can't read the body
The error goes away, but the response becomes "opaque": you can't read the status or the body. You've hidden the error, not fixed it.
❌ Access-Control-Allow-Origin: * everywhere
For a public API (a weather API anyone can use), * is correct. But if you put * on authenticated APIs out of habit, it breaks the moment you add cookie auth, and you skip thinking about who should actually be allowed. Get in the habit of naming allowed origins.
❌ Disabling CORS with a browser extension or launch flag
It only works in your browser; your users' browsers still block it. Even for local testing, it tends to hide the real problem, so it's not recommended.
✅ In development, a proxy is a valid option
If the frontend dev server forwards API requests for you, the browser sees a same-origin request and CORS never comes up.
// vite.config.js
export default {
server: {
proxy: {
"/api": "http://localhost:5001", // forward /api/* requests to the API server
},
},
};
In production, if the frontend and API live under the same domain (for example my-service.com/api), you don't need CORS settings at all.
CORS does not protect your server
One last thing you need to know. As the first example showed, a simple request blocked by CORS still reaches the server and runs. CORS stops the browser from reading responses from other origins; it is not a firewall that blocks requests to your server.
- CORS doesn't apply to curl, Postman, or server-to-server requests at all.
- Blocking requests that change data is the job of authentication, authorization checks, and CSRF protection.
CORS settings decide "who can read my API's responses in a browser." "Who can call my API" has to be protected separately with authentication and authorization.
Summary: fixes by error message
| Phrase in the error | Cause | What to do on the server |
|---|---|---|
| No 'Access-Control-Allow-Origin' header | Missing allow header | Add Access-Control-Allow-Origin |
| not allowed by Access-Control-Allow-Headers in preflight | Header not allowed in preflight | Add Allow-Headers to the OPTIONS response |
| Method PUT is not allowed | Method not allowed in preflight | Add Allow-Methods to the OPTIONS response |
| must not be the wildcard '*' when ... credentials | Cookies + * | Exact origin + Allow-Credentials: true |
| (no error) response header is null | Header not exposed | Add Access-Control-Expose-Headers |
When you hit a CORS error, before touching frontend code, read the error message for "what didn't the browser get permission for from the server?" The answer is almost always one response header on the server.