Very few error messages waste as much junior developer time as the CORS error in the browser console. The code looks correct, the endpoint works perfectly in Postman, the JSON is valid, and yet the browser refuses to hand the response to your JavaScript. This guide explains what is actually happening when you see that message, why the fix almost never belongs in your frontend code, and how to solve it properly in development and in production.
What a CORS error actually means
CORS stands for Cross-Origin Resource Sharing. It is not a bug, it is a browser security rule. An “origin” is the combination of scheme, host and port. http://localhost:3000 and http://localhost:5000 are two different origins because the ports differ. So are https://devqivo.com/ and http://example.com, because the scheme differs.
By default, the browser lets your page send a request to another origin, but it will not let your JavaScript read the response unless the server that owns the data explicitly gives permission. That permission arrives as a response header, usually Access-Control-Allow-Origin.
This is the single most important sentence in this article: the error appears in your browser, but the fix lives on the server. The request usually left your machine, reached the API, and came back fine. The browser simply blocked your code from touching the result because the permission header was missing.
Why Postman works but the browser does not
Postman, curl and your backend HTTP client are not browsers. They do not implement the same-origin policy, so they never check for CORS headers. That is why an endpoint can feel “fine” in every tool except the one that matters. If someone tells you the API is working because curl returns 200, they have not tested the thing that is failing.
Simple requests versus preflighted requests
The browser splits cross-origin requests into two categories, and knowing which one you are sending explains most confusing cases.
A simple request is a GET, HEAD or POST with only a small set of allowed headers, and a Content-Type of text/plain, multipart/form-data or application/x-www-form-urlencoded. The browser sends it straight away and checks the response headers afterwards.
Anything else triggers a preflight. Before your real request, the browser silently sends an OPTIONS request asking the server what it allows. This catches people out constantly, because the two most common frontend habits both trigger preflight: sending Content-Type: application/json, and sending an Authorization header.
fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({ name: "Sara" })
});
That innocent-looking call produces an OPTIONS request first. If your server has no route handler for OPTIONS, it will answer 404 or 405, the preflight fails, and the real POST is never sent at all. In the Network tab you will see an OPTIONS row in red and no POST row underneath it. That pattern is the fingerprint of a failed preflight.
Reading the error message properly
Browsers are unusually helpful here if you read the whole sentence instead of the first five words.
- “No ‘Access-Control-Allow-Origin’ header is present” — the server sent nothing at all. Add the header on the server.
- “The ‘Access-Control-Allow-Origin’ header has a value that is not equal to the supplied origin” — the server allows a different origin. Usually a trailing slash, a
wwwmismatch, or http versus https. - “Response to preflight request doesn’t pass access control check” — the OPTIONS request failed. Check that OPTIONS is routed and returns 204 or 200.
- “Method PATCH is not allowed by Access-Control-Allow-Methods” — add the method to that header.
- “Request header field x-api-key is not allowed by Access-Control-Allow-Headers” — every custom header must be listed explicitly.
- “The value of the ‘Access-Control-Allow-Origin’ header must not be the wildcard when the credentials mode is ‘include'” — covered below, this one has its own rule.
Fixing it on a Node and Express backend
If you control the API, this is a five-minute job. The cors package handles preflight routing automatically.
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors({
origin: ["http://localhost:3000", "https://myapp.com"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true
}));
Two details matter. First, place this middleware above your routes, not below them, or requests will be handled before the headers are attached. Second, resist the temptation to ship app.use(cors()) with no options to production. The bare form allows every origin on the internet to call your API from any website, which is fine for a public read-only endpoint and dangerous for anything authenticated.
Fixing it without the cors package
Sometimes you need to understand the raw headers, especially on a platform with its own routing layer.
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "https://myapp.com");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.header("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Access-Control-Max-Age is worth adding. It tells the browser to cache the preflight answer, so it stops sending an OPTIONS request before every single call. On a chatty dashboard this roughly halves your request count.
Cookies, sessions and the credentials rule
If your API uses cookie-based sessions, the cross-origin rules tighten considerably. The browser will not attach cookies to a cross-origin request unless you ask it to:
fetch("https://api.example.com/me", { credentials: "include" });
Once you do that, three server-side conditions become mandatory. Access-Control-Allow-Credentials must be true. Access-Control-Allow-Origin must be an exact origin, never the * wildcard. And the cookie itself must be issued with SameSite=None; Secure, which means the whole thing only works over HTTPS.
Developers commonly get two of the three right and then lose an afternoon. If credentials are involved, check all three before changing anything else.
The development proxy, and why it is the better local fix
During local development you often cannot edit the API at all — it might belong to another team or a third party. A dev proxy solves this cleanly. Your frontend calls a relative path on its own origin, and the dev server forwards the request to the real API from the server side, where CORS does not apply.
In a Vite project:
export default {
server: {
proxy: {
"/api": {
target: "https://api.example.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "")
}
}
}
};
Create React App uses a "proxy" field in package.json, and Next.js uses rewrites() in its config. In every case you then call fetch("/api/users") and the browser sees a same-origin request.
The benefit beyond convenience is that your code no longer contains hardcoded API hosts, which means the same code works in production behind a reverse proxy.
Things that look like fixes but are not
A few popular suggestions deserve a clear warning.
Browser extensions that disable CORS. They only change your machine. Every user of your deployed site will still hit the error, and you will discover this after launch.
Launching Chrome with security flags disabled. Same problem, plus you are browsing the rest of the web with protections turned off.
Setting the headers on the request instead of the response. Adding "Access-Control-Allow-Origin" to your fetch headers does nothing useful and will itself trigger a preflight failure, because it is not an allowed request header.
mode: "no-cors". This one is genuinely misleading. It stops the error, which feels like success, but it returns an opaque response: status 0, no readable body, no headers. You cannot use the data. It exists for fire-and-forget cases like pixel beacons, not for fetching JSON.
Public CORS proxies. Fine for a weekend prototype. In production you are routing user data through a stranger’s server, accepting their uptime and their rate limits, and often leaking auth tokens.
A practical debugging order
When the error appears, work through this sequence rather than guessing.
- Open the Network tab and find the failing request. Note whether an OPTIONS row exists and what status it returned.
- Inspect the response headers of that OPTIONS request. If
Access-Control-Allow-Originis absent, the server config is the problem, full stop. - Compare the allowed origin character by character against your page origin, including protocol and trailing slash.
- Check whether you are sending credentials. If so, verify the wildcard is not in use.
- Confirm the middleware or header logic runs before routing and before any authentication guard. A 401 from an auth layer on the OPTIONS request breaks preflight, because preflight requests carry no credentials by design.
- Only then change frontend code.
Frequently asked questions
Can I fix a CORS error purely in frontend code?
No, not for a real fix. The permission must come from the server that owns the data. Your only frontend-side options are to route the call through a proxy you control or through your own backend.
Why does the request appear in my server logs even though it failed?
Because it was never blocked on the way out. The browser sent it, the server processed it, and the block happened when the response came back without permission headers. This is also why CORS should never be treated as a substitute for authentication.
Does CORS protect my API?
Only partially, and not in the way people assume. It protects users from malicious sites reading their authenticated data in a browser. It does nothing against a script, a server or a command-line client. Your API still needs real authentication and authorisation.
Why does it work in production but fail locally?
Almost always because production serves the frontend and API from the same origin, or behind one reverse proxy, so no cross-origin request exists. Locally you are running two ports. A dev proxy closes that gap.
Conclusion
CORS stops being mysterious once you accept its basic shape: the browser enforces it, the server permits it, and the preflight request is where most failures actually happen. Read the full error text, check the OPTIONS request in the Network tab, fix the headers at the source, and use a dev proxy locally instead of disabling browser protections. That handles the overwhelming majority of cases you will ever meet.