No Access-Control-Allow-Origin Header Is Present: What CORS Is Actually Doing
CORS errors are a browser protecting you from a response it already received. Understanding that one fact makes every CORS error obvious.
The short answer
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
The request succeeded. The server received it, processed it, and responded. The browser then refused to show your JavaScript the response, because the server did not say your origin was allowed to read it.
CORS is enforced by the browser, not the server. Which means:
- You cannot fix it in your frontend code
curland Postman will always work, because they do not enforce CORS- The fix always goes on the server that owns the resource
Tested against Chrome 133 and Node 22.14.
The one thing that makes CORS make sense
CORS is not stopping your request. It is stopping you from reading the response.
The reason is the Same Origin Policy, and the threat it defends against is not what most people assume. Consider a malicious site you visit. It runs:
fetch("https://yourbank.com/api/accounts", { credentials: "include" })
.then(r => r.json())
.then(data => sendToAttacker(data));
Your browser has your bank session cookie. Without the Same Origin Policy, the request goes out with your cookies, the bank responds with your account data, and the attacker's JavaScript reads it.
CORS is the mechanism by which a server can opt in to letting other origins read its responses. The default is no, and that default is correct.
Once you hold that framing, the error messages stop being mysterious. Every one of them is the browser saying "the server did not grant your origin permission to read this".
Simple requests and preflights
Not every cross origin request behaves the same way.
A simple request goes straight out. It qualifies if it is GET, HEAD, or POST, and its headers are limited to a small safelist, and its Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.
Anything else triggers a preflight: the browser first sends an OPTIONS request asking permission, and only sends the real request if the answer allows it.
OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Content-Type: application/json triggers a preflight. That is why almost every API call from a modern frontend is preflighted, and it is the single most common source of surprise. Sending a custom header like Authorization or X-Request-Id does the same.
The practical consequence: your server must handle OPTIONS on every route that accepts cross origin requests. A router that only defines POST /data will return 404 or 405 to the preflight, and the browser will block the real request with a message that says nothing about OPTIONS.
Reading the specific error
The message tells you which rule failed.
No 'Access-Control-Allow-Origin' header is present
The server sent no CORS headers at all. Either it does not implement CORS, or the request never reached your CORS middleware. That second case matters: if the request 500s before the middleware runs, the error response has no CORS headers, and you see a CORS error masking an application error. Always check the Network tab for the actual status code.
The 'Access-Control-Allow-Origin' header has a value 'https://app.example.com' that is not equal to the supplied origin
Close but not exact. Origin matching is a string comparison including scheme, host, and port. http and https differ. example.com and www.example.com differ. A trailing slash breaks it. Port 3000 and port 3001 differ.
Response to preflight request doesn't pass access control check
The OPTIONS request failed. Look at the OPTIONS response in the Network tab, not the real request. Common cause: your auth middleware runs before CORS and rejects OPTIONS with a 401, because preflight requests do not carry credentials.
Request header field x-custom-header is not allowed by Access-Control-Allow-Headers
You are sending a header the server did not list. Add it to Access-Control-Allow-Headers. The list is not a wildcard by default.
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'
The important one. You cannot combine Access-Control-Allow-Origin: * with credentials. The spec forbids it, because a wildcard plus cookies would recreate exactly the vulnerability CORS exists to prevent.
To send cookies you must echo the specific origin and set Access-Control-Allow-Credentials: true.
Configuring it correctly
The common wrong answer, which you will find everywhere:
app.use(cors()); // Access-Control-Allow-Origin: *
That makes any website able to read your API's responses. For a genuinely public read only API that is fine. For anything authenticated it is a mistake, and it will not work with cookies anyway.
The version I would ship:
import cors from "cors";
const allowed = new Set([
"https://app.example.com",
"https://admin.example.com",
...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000"] : []),
]);
app.use(cors({
origin(origin, cb) {
// no origin: same-origin requests, curl, mobile apps
if (!origin) return cb(null, true);
if (allowed.has(origin)) return cb(null, true);
cb(new Error(`Origin not allowed: ${origin}`));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
exposedHeaders: ["X-Total-Count", "X-Request-Id"],
maxAge: 86400,
}));
Two details worth understanding.
exposedHeaders is the one people miss. By default, JavaScript can only read a handful of response headers. If your API returns pagination info in X-Total-Count and the frontend cannot see it, this is why. The header arrives, the browser hides it.
maxAge caches the preflight. Without it, every single request is preceded by an OPTIONS round trip, which doubles your request count and adds latency on every call. Chrome caps this at 2 hours regardless of what you send, but setting it is still worth it.
Order your middleware correctly. CORS must run before authentication:
app.use(cors(corsOptions)); // first
app.use(authenticate); // second
app.use(routes);
Reversed, your auth middleware rejects the credential-free preflight and CORS never gets a chance to respond.
Vary on Origin when echoing origins dynamically, or a CDN will cache one origin's CORS header and serve it to another:
res.setHeader("Vary", "Origin");
The cors package does this for you. Hand rolled implementations frequently do not, and the resulting bug is intermittent and cache dependent, which makes it genuinely unpleasant to debug.
Development workarounds and why to be careful
A dev server proxy is the right approach locally. Requests become same origin, so CORS does not apply at all:
// vite.config.js
export default {
server: {
proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } },
},
};
This is also why "it works in development and breaks in production" happens so often with CORS. Your dev proxy was hiding the problem.
Do not use a browser extension that disables CORS. It fixes your machine and nobody else's, and it means you will not discover the problem until a user does.
Do not use a public CORS proxy for anything real. You are routing your users' requests, including their credentials, through a stranger's server.
Debugging checklist
- Open the Network tab and find the actual request. Is there an OPTIONS before it?
- What status did the OPTIONS get? Anything other than 2xx is your problem.
- What CORS headers came back? Compare the origin value character by character with what the browser sent in
Origin. - Does the real request return 5xx? An error response without CORS headers presents as a CORS error.
- Reproduce with curl to confirm the server side is fine:
curl -i -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
If curl shows the right headers and the browser still complains, look for something between you and the server: a CDN stripping headers, a load balancer not forwarding OPTIONS, or an API gateway with its own CORS configuration that overrides yours.
That last one catches people regularly. If you configure CORS in both your application and your gateway, one of them wins and it is usually not the one you edited.