Home/Blog/What is CORS and How to Fix It in Express - Properly Explained
What is CORS and How to Fix It in Express - Properly Explained
11 min readJul 2, 2026

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.

2 views0 likes0 comments0 bookmarks

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.

Comments

Sign in to join the conversation

Related Articles

More insights you might enjoy

SSR, CSR, SSG, and ISR in Next.js - What They Are and When to Use Each One

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.

Sadie Sink·9 min
1181
React vs Next.js: What's Actually Different and When to Use Which

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.

Sadie Sink·8 min
1430
HTTP Status Codes Explained - What They Actually Mean and Why Developers Should Know Them

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.

Sadie Sink·10 min
730
How React's useEffect Actually Works - And Why Developers Misuse It

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.

Sadie Sink·8 min
530
Node.js and Express.js - What They Are, How They Differ, and How They Work Together

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.

Sadie Sink·9 min
510
TypeScript vs JavaScript - What Is Actually Different and Should You Learn TypeScript

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.

Sadie Sink·8 min
490