
What is CORS and How to Fix It in Express - Properly Explained
CORS errors are one of the most searched JavaScript errors. This blog explains what CORS actually is, why browsers enforce it, what preflight requests are, and how to configure it correctly in Express.
If you've built a frontend app that talks to a backend API, you've probably seen this error at some point:
Access to fetch at 'http://localhost:3000/api/users' from origin
'http://localhost:5173' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Most developers at this point Google "how to fix CORS", find a Stack Overflow answer, copy a few lines of code, and move on. The error goes away and they never really understand what just happened or why.
That's a problem - because CORS errors show up again in production in ways that are harder to debug, and if you don't understand the mechanism, you end up either breaking things trying to fix them or making your API insecure without realizing it.
This blog explains what CORS actually is, why browsers enforce it, what happens under the hood when a request gets blocked, and how to handle it correctly in an Express API.
Start Here - What is an Origin
Before understanding CORS you need to understand what an "origin" is, because that's the core concept everything else builds on.
An origin is the combination of three things: the protocol, the domain, and the port.
http://localhost:5173
protocol: http
domain: localhost
port: 5173
Two URLs have the same origin only if all three of these match exactly. If any one of them is different, they are considered different origins.
http://localhost:5173 and http://localhost:3000 -> different origins (different port)
http://localhost:5173 and https://localhost:5173 -> different origins (different protocol)
http://localhost:5173 and http://codekk.dev -> different origins (different domain)
http://localhost:5173 and http://localhost:5173 -> same origin
This distinction matters because of something called the Same-Origin Policy.
The Same-Origin Policy - Why This Exists at All
Browsers have a built-in security rule called the Same-Origin Policy. It says that JavaScript running on one origin cannot read responses from a different origin by default.
This is not a bug or an accident. It is a deliberate security feature and understanding why it exists makes everything else make sense.
Imagine you are logged into your bank at https://mybank.com. Your browser has a session cookie stored for that bank. Now imagine you visit a malicious website at https://evil.com. That site has JavaScript that silently makes a request to https://mybank.com/api/transfer-money using your cookies - because your browser automatically sends cookies for any domain they belong to.
Without the Same-Origin Policy, that request would succeed and the bank's API would process it as if you made it yourself, because your cookies are attached. The malicious site's JavaScript could then read the response and steal your data.
The Same-Origin Policy prevents this. It blocks https://evil.com's JavaScript from reading responses from https://mybank.com. The request might go through, but the browser refuses to give the response to the JavaScript that made it if the origins don't match.
So CORS errors are not the browser being annoying. The browser is actively protecting users from a real class of attacks.
So What is CORS Then
CORS stands for Cross-Origin Resource Sharing. It is a mechanism that lets servers tell browsers "it's okay, this other origin is allowed to read my responses."
It works through HTTP headers. When a browser makes a cross-origin request, the server can include special headers in its response that say which origins, methods, and headers are permitted. If those headers are present and allow the requesting origin, the browser lets the JavaScript read the response. If they're not present or don't allow the requesting origin, the browser blocks it.
CORS is not a restriction added by the server. It's a relaxation of the browser's default restriction, controlled by the server.
That's an important distinction. The server decides who gets to talk to it across origins. The browser enforces that decision.
Why Your Localhost Setup Triggers CORS
This is the most common scenario. You have a React or Vite frontend running on http://localhost:5173 - if you read our blog on dark and light mode in React Vite, you know Vite runs on port 5173 by default.
Your Express backend is running on http://localhost:3000 - as we set up in the Node.js and Express blog.
Even though both are on localhost, they're on different ports. Different ports mean different origins. So when your frontend tries to call your backend API, the browser sees a cross-origin request and checks if the server allows it. If your Express server doesn't send back CORS headers, the browser blocks the response.
The request actually reaches your server. Your server processes it and sends a response. But the browser intercepts that response and refuses to give it to your JavaScript because the CORS headers are missing. This is why you'll sometimes see the request in your server logs even when the browser shows a CORS error.
Simple Requests vs Preflight Requests
Not all cross-origin requests work the same way. There are two types you need to know about.
Simple Requests
Some requests are considered "simple" and the browser just makes them directly. A simple request is a GET or POST with basic content types like text/plain or application/x-www-form-urlencoded, with no custom headers.
The browser makes the request, the server responds, and the browser checks the CORS headers. If they're missing or wrong, the browser blocks the JavaScript from reading the response.
Preflight Requests
For anything more complex - like a POST with Content-Type: application/json, a PUT, a DELETE, or a request with custom headers like Authorization - the browser first sends a preliminary request using the HTTP OPTIONS method. This is called a preflight request.
The browser is essentially asking the server: "Before I send this actual request, is this kind of request allowed from this origin?"
OPTIONS /api/users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
The server needs to respond to this preflight with the appropriate CORS headers saying what's allowed. Only if the preflight response says "yes, that's allowed" will the browser then send the actual request.
This is why sometimes a single API call in your browser's network tab shows two requests - an OPTIONS request followed by the actual one. That's the preflight happening.
If your server doesn't handle the OPTIONS method, the preflight fails, and your actual request never goes through. This trips up a lot of developers who handle GET and POST in their Express routes but forget about OPTIONS.
What the CORS Headers Actually Mean
Let's look at the headers involved so you know what you're actually configuring.
Access-Control-Allow-Origin
This is the most important one. It tells the browser which origin is allowed to read responses.
Access-Control-Allow-Origin: http://localhost:5173
This allows only your frontend origin. Or you can use a wildcard:
Access-Control-Allow-Origin: *
This allows any origin. Convenient for public APIs, but you should never use this for APIs that handle authenticated requests or sensitive data - because the wildcard cannot be combined with Access-Control-Allow-Credentials: true.
Access-Control-Allow-Methods
Tells the browser which HTTP methods are permitted.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers
Tells the browser which request headers are permitted.
Access-Control-Allow-Headers: Content-Type, Authorization
If you're sending a JSON body (Content-Type: application/json) or an auth token (Authorization: Bearer ...), these headers need to be listed here.
Access-Control-Allow-Credentials
This one is specifically for requests that include cookies or HTTP authentication. If you set this to true, the browser will include cookies in cross-origin requests and allow the JavaScript to read responses to those requests.
Access-Control-Allow-Credentials: true
Important: when Access-Control-Allow-Credentials is true, you cannot use * for Access-Control-Allow-Origin. You must specify the exact origin. This is a browser security rule that cannot be bypassed.
Access-Control-Max-Age
Tells the browser how long it can cache the preflight response, in seconds. This avoids sending an OPTIONS request before every single API call.
Access-Control-Max-Age: 86400
Fixing CORS in Express - The Right Way
Now that you understand what's happening, let's fix it properly. If you've set up an Express server following our Node.js and Express blog, this is where you add CORS support.
The standard approach is to use the cors npm package, which handles all the header setting for you.
npm install cors
Basic setup - allow all origins:
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
app.get("/api/users", (req, res) => {
res.json({ users: [] });
});
app.listen(3000, () => console.log("Server running on port 3000"));
app.use(cors()) with no configuration allows all origins. This is fine for a public API that has no authentication and serves non-sensitive data. Do not use this for authenticated APIs.
Specific origin - the correct approach for most apps:
const corsOptions = {
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 86400
};
app.use(cors(corsOptions));
This locks CORS down to exactly your frontend origin, permits the methods and headers you actually need, and allows credentials if you're using cookies or auth tokens.
Multiple allowed origins:
In real projects you often have more than one frontend - a local development URL, a staging URL, and a production URL. Here's how to handle that:
const allowedOrigins = [
"http://localhost:5173",
"https://staging.codekk.dev",
"https://codekk.dev"
];
const corsOptions = {
origin: function (origin, callback) {
// allow requests with no origin (like mobile apps or Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS policy: origin ${origin} is not allowed`));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true
};
app.use(cors(corsOptions));
The origin option here takes a function instead of a string. The function receives the incoming origin and a callback. If the origin is in the allowed list, you call callback(null, true) to allow it. If not, you call callback(new Error(...)) to reject it.
The !origin check handles tools like Postman, curl, or mobile app clients that don't send an Origin header. Without this, those requests would also get blocked.
Handling preflight requests explicitly:
The cors() middleware handles OPTIONS requests automatically when you use app.use(cors()). But if you're applying CORS to specific routes only and have complex routing, you might need to handle OPTIONS explicitly:
app.options("*", cors(corsOptions)); // handle preflight for all routes
app.use(cors(corsOptions));
The app.options("*", cors()) line tells Express to respond to OPTIONS requests on any route using the CORS middleware. This makes sure preflight requests get proper responses.
Doing It Without the cors Package
It's worth knowing how to set these headers manually, so you understand what the package is doing under the hood. This is also useful for situations where you're writing middleware yourself, similar to what we covered in the Node.js and Express blog.
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
// handle preflight
if (req.method === "OPTIONS") {
return res.status(200).end();
}
next();
});
This does exactly what the cors package does - sets the headers on every response and returns a 200 for OPTIONS preflight requests. Using the package is cleaner and handles edge cases for you, but knowing this manual version means you're never confused about what CORS middleware is actually doing.
Common Mistakes That Keep the Error Coming Back
Putting CORS after your routes
Middleware order matters in Express. If you register your routes before app.use(cors()), the CORS headers won't be on those responses. Always put app.use(cors()) before your route definitions.
// Wrong
app.get("/api/users", handler);
app.use(cors()); // too late
// Correct
app.use(cors()); // before routes
app.get("/api/users", handler);
Using wildcard with credentials
// This will not work - browser will throw an error
app.use(cors({
origin: "*",
credentials: true
}));
When credentials are involved, the browser requires a specific origin, not a wildcard. This is a hard browser rule. Specify the exact origin instead.
Forgetting to allow the Authorization header
If you're sending a JWT token in your request headers like Authorization: Bearer <token>, and you haven't listed Authorization in allowedHeaders, the preflight will fail and your request won't go through.
Only fixing the frontend
CORS is a server-side fix. You cannot solve CORS errors by changing your frontend code. The headers have to come from the server. If you see someone suggesting you add a proxy or change fetch options to fix CORS, that's a workaround for development only - not a real fix for production.
CORS in Production is Different From Development
In development, your frontend and backend are both on localhost with different ports. In production, they might be on completely different domains - your frontend on https://codekk.dev and your API on https://api.codekk.dev.
Make sure your production Express server's allowed origins list includes your actual production frontend URL, not just localhost. A common mistake is fixing CORS for development and then forgetting to update the configuration for production, which leads to a working local app and a broken production app.
Using environment variables for your allowed origins is the clean way to handle this:
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(",")
: ["http://localhost:5173"];
const corsOptions = {
origin: function (origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true
};
Set ALLOWED_ORIGINS=https://codekk.dev in your production environment and http://localhost:5173 locally. No code changes needed between environments.
The One Line Summary
CORS errors happen because your browser's Same-Origin Policy blocked a cross-origin response. You fix them by having your server send the right headers telling the browser which origins, methods, and headers are allowed. The cors package in Express handles this cleanly, but knowing the manual version means you understand what's actually happening and can debug it confidently when things go wrong.
Next time you see that red CORS error in the console, you'll know exactly what caused it, exactly what the browser is protecting you from, and exactly what to change on the server to fix it properly.
Related Articles
More insights you might enjoy

SSR, CSR, SSG, and ISR in Next.js - What They Are and When to Use Each One
Next.js gives you four ways to render pages and each solves a different problem. This guide explains all four with real code examples and a practical decision guide.

React vs Next.js: What's Actually Different and When to Use Which
React and Next.js are often confused - one is a UI library, the other is a full framework built on top of it. This post breaks down the real differences: rendering strategies, routing, API routes, and when to pick one over the other.

HTTP Status Codes Explained - What They Actually Mean and Why Developers Should Know Them
Most developers know 200 and 404. But understanding the full range - and when to use them in your own APIs - makes you a noticeably better backend developer.

How React's useEffect Actually Works - And Why Developers Misuse It
useEffect is one of the most used hooks in React and also one of the most misunderstood. This guide covers the dependency array, cleanup functions, the double render, and common mistakes that cause bugs.

Node.js and Express.js - What They Are, How They Differ, and How They Work Together
Node.js and Express.js are not the same thing. This guide explains what each does, how they work together, and covers routing, middleware, and error handling with real code.

TypeScript vs JavaScript - What Is Actually Different and Should You Learn TypeScript
TypeScript and JavaScript are more related than most people think. This guide covers the real difference, core TypeScript features you need to know, and helps you decide whether to make the switch.

Comments
Sign in to join the conversation