Skip to content

Fix: CORS Not Working in Express (Access-Control-Allow-Origin Missing)

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix CORS errors in Express.js — cors middleware not applying, preflight OPTIONS requests failing, credentials not allowed, and specific origin whitelisting issues.

The Error

Your Express API returns responses, but the browser blocks them with:

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.

Or for requests with credentials:

Access to XMLHttpRequest at 'http://api.example.com/data' from origin 'http://app.example.com'
has been blocked by CORS policy: 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'.

Or for preflight requests:

Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.

The API works fine with curl or Postman — only browser requests are blocked.

Why This Happens

CORS (Cross-Origin Resource Sharing) is enforced by the browser, not the server. When a browser makes a request to a different origin (different domain, protocol, or port), it checks the response headers to decide whether to allow the JavaScript code to read the response. The server is fully aware of the request and even sends a response body — the browser simply refuses to hand that body to your JavaScript without the right headers.

The crucial detail: Express does not block the request. Your handler runs, your database query executes, and the response is sent. Then the browser tosses the result because the Access-Control-Allow-Origin header is missing or does not match. This is why tools like curl, Postman, and server-to-server clients never see CORS errors — they do not enforce the policy.

Common causes in Express:

  • cors middleware not installed or not applied before route handlers.
  • Middleware ordercors() added after the routes it should cover.
  • Preflight OPTIONS requests not handled — the browser sends an OPTIONS request first for non-simple requests, and Express returns 404 if no handler exists.
  • Wildcard * with credentialsAccess-Control-Allow-Origin: * cannot be used with credentials: 'include'.
  • Wrong origin in whitelist — trailing slash, wrong port, or http vs https mismatch.
  • CORS headers on error responses — if your error handler runs before cors middleware, error responses (4xx, 5xx) lack CORS headers and the browser blocks them.
  • An upstream proxy strips headers before the response reaches the browser, even though Express set them correctly.

Platform and Environment Differences

CORS misbehavior often depends on where Express is running, not just the code. Walk through each layer between your browser and the handler before changing middleware.

Behind nginx, Caddy, or HAProxy in production. A reverse proxy can rewrite, drop, or duplicate Access-Control-Allow-Origin headers. Some defaults strip headers Express adds when the upstream returns a 4xx or 5xx status. With nginx, only add_header directives at the matched location block apply — directives at the server block get overridden the moment any add_header exists in a child block. With Caddy, the reverse_proxy directive passes upstream headers through by default, but header_up and header_down rules can rewrite them. Always test the production stack with curl -i from outside, not from inside the container.

AWS API Gateway in front of Lambda or Express via serverless-http. API Gateway has its own CORS configuration. If you enable CORS on the API Gateway resource, Gateway answers OPTIONS itself with a mock integration and your Express OPTIONS handler never runs. If you also set CORS in Express, you get duplicated headers and the browser rejects the response. Pick one layer.

Cloudflare and WAF rules. Cloudflare’s Transform Rules, Workers, and managed WAF can strip or rewrite response headers. The “Email Address Obfuscation” and “Rocket Loader” features can also modify responses in ways that interact poorly with strict CORS. If your local Express setup works but production fails, look at the Cloudflare dashboard’s Rules and Workers tabs before changing code.

Browser behavior diverges. Safari is stricter than Chrome about credentials and SameSite cookies on cross-origin requests, and it logs CORS errors differently — Safari shows the failure in the Network tab without a console explanation, while Chrome usually prints the exact reason in the console. Firefox sometimes caches preflight responses longer than Access-Control-Max-Age suggests, which makes header changes look like they did not apply until you hard-reload.

Mobile WebViews and Electron. Android WebView and iOS WKWebView apply CORS like a browser, but the origin can be null or file:// when content is loaded from disk. The Electron renderer process is a Chromium tab and enforces CORS unless webSecurity: false is set on the BrowserWindow. Disabling webSecurity for development hides real CORS bugs that surface again in packaged builds — fix the headers rather than disabling enforcement. For Electron-specific renderer issues, see Fix: Electron not working.

Fix 1: Install and Apply cors Middleware Correctly

The cors npm package is the standard solution. Apply it before your routes:

npm install cors
npm install -D @types/cors  # TypeScript
const express = require("express");
const cors = require("cors");

const app = express();

// Apply BEFORE all routes
app.use(cors());

app.get("/api/users", (req, res) => {
  res.json({ users: [] });
});

app.listen(3000);

Verify middleware order — this is the most common mistake:

// BROKEN — cors applied after the route
app.get("/api/users", handler);
app.use(cors()); // Too late — /api/users already has no CORS headers

// FIXED — cors applied before all routes
app.use(cors());
app.get("/api/users", handler);

Common Mistake: Adding app.use(cors()) at the bottom of the file after all routes are defined. Express middleware runs in order — CORS headers are only added to routes defined after app.use(cors()).

Fix 2: Configure Specific Origins (Do Not Use * in Production)

cors() with no options allows all origins (*). For production, restrict to your actual frontend origins:

const corsOptions = {
  origin: "https://app.example.com",  // Single allowed origin
  methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
};

app.use(cors(corsOptions));

Allow multiple origins:

const allowedOrigins = [
  "https://app.example.com",
  "https://www.example.com",
  "http://localhost:5173",  // Local dev
  "http://localhost:3000",
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (curl, Postman, server-to-server)
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    }
  },
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
}));

Allow origins by pattern (subdomains):

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || /\.example\.com$/.test(origin)) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed by CORS"));
    }
  },
}));

Fix 3: Handle Preflight OPTIONS Requests

For non-simple requests (custom headers, PUT/DELETE/PATCH methods, Content-Type: application/json), the browser sends a preflight OPTIONS request before the actual request. Express must respond to it with 200 OK and the correct CORS headers.

The cors package handles this automatically when applied globally:

app.use(cors(corsOptions));
// OPTIONS requests are now handled for all routes

If you apply cors per-route, add explicit OPTIONS handling:

// Per-route cors
app.get("/api/users", cors(corsOptions), getUsers);
app.post("/api/users", cors(corsOptions), createUser);

// Must also handle OPTIONS preflight for each path
app.options("/api/users", cors(corsOptions));

// Or handle all OPTIONS requests globally
app.options("*", cors(corsOptions));

If you are not using the cors package, set headers manually:

app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "https://app.example.com");
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.header("Access-Control-Max-Age", "86400"); // Cache preflight for 24h

  if (req.method === "OPTIONS") {
    return res.sendStatus(204); // No content — preflight response
  }
  next();
});

Fix 4: Fix Credentials with CORS

When using credentials: 'include' in fetch (to send cookies or HTTP auth), two additional requirements apply:

  1. Access-Control-Allow-Origin must be the specific origin, not *.
  2. Access-Control-Allow-Credentials: true must be set.

Broken — wildcard with credentials:

app.use(cors()); // Sets Access-Control-Allow-Origin: *
// Frontend — fails with credentials error
fetch("http://localhost:3000/api/profile", {
  credentials: "include", // Sends cookies
});

Fixed:

app.use(cors({
  origin: "http://localhost:5173",  // Specific origin — not *
  credentials: true,                // Sets Access-Control-Allow-Credentials: true
}));
// Frontend
fetch("http://localhost:3000/api/profile", {
  credentials: "include",
});

For dynamic allowed origins with credentials:

app.use(cors({
  origin: (origin, callback) => {
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed"));
    }
  },
  credentials: true,
}));

Why this matters: The * wildcard means “any origin can read this response.” Allowing credentials from any origin would let any website make authenticated requests on behalf of your users — a security risk. Browsers enforce this combination as an error.

Fix 5: Add CORS Headers to Error Responses

If your Express error handler runs independently of the cors middleware, error responses (400, 401, 500) may lack CORS headers. The browser then blocks the error response, and your frontend only sees a network error — not the actual error status or message.

Broken — CORS headers missing on errors:

app.use(cors()); // Adds CORS to normal responses

app.get("/api/data", (req, res) => {
  throw new Error("Something went wrong");
});

// Global error handler — runs without CORS middleware context
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
  // No Access-Control-Allow-Origin header — browser blocks this
});

Fixed — apply cors inside the error handler too:

const corsMiddleware = cors(corsOptions);

app.use((err, req, res, next) => {
  corsMiddleware(req, res, () => {
    res.status(err.status || 500).json({ error: err.message });
  });
});

Or simply apply cors before the error handler and ensure it handles all responses:

app.use(cors(corsOptions)); // This actually works for errors too in most setups

// Route that throws
app.get("/api/data", asyncHandler(async (req, res) => {
  throw new Error("Oops");
}));

app.use((err, req, res, next) => {
  // CORS headers are already set by the middleware above
  res.status(500).json({ error: err.message });
});

Fix 6: Fix CORS Behind a Reverse Proxy (nginx)

If nginx sits in front of Express, CORS headers can be added at the nginx level. But if both nginx and Express add CORS headers, the browser may reject the duplicate headers:

The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.

Fix — add CORS in one place only:

Either handle CORS in nginx (and remove it from Express):

location /api/ {
  proxy_pass http://localhost:3000/;

  add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
  add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
  add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

  if ($request_method = 'OPTIONS') {
    return 204;
  }
}

Or handle CORS in Express only (and remove nginx CORS directives). For most setups, handling it in Express is simpler and keeps the logic in code rather than server config.

For general nginx proxy issues, see Fix: Nginx 502 Bad Gateway.

Fix 7: Debug CORS Issues

Check the actual response headers:

curl -I -X OPTIONS \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  http://localhost:3000/api/users

The response should include:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

Log the origin in development:

app.use((req, res, next) => {
  console.log("Origin:", req.headers.origin);
  next();
});
app.use(cors(corsOptions));

Verify the origin your frontend sends exactly matches what you have in the whitelist — a missing port, http vs https, or trailing slash causes a mismatch.

Still Not Working?

Check for Access-Control-Allow-Origin already set elsewhere. If a framework or proxy is setting this header before your cors middleware, you may have duplicate values.

Check the browser’s Network tab. In Chrome DevTools, go to the Network panel, select the failed request, and inspect the Headers tab. Check the actual response headers sent, and the error in the Console tab. The browser gives a specific reason for each CORS rejection.

Check for redirect loops. If your Express app redirects the request (301/302), the redirected response also needs CORS headers. The cors package with app.use() covers all responses including redirects.

Check for HTTP vs HTTPS mismatch. http://localhost:5173 and https://localhost:5173 are different origins. Make sure your allowed origins list uses the correct protocol.

Check Cloudflare and CDN cache. A CDN that cached a response without CORS headers will keep serving the broken response even after you fix the server. Purge the cache, then add Vary: Origin to your responses so browsers and CDNs cache per-origin instead of serving the wrong header to the wrong client.

Check service worker interception. A registered service worker that proxies fetch requests can return a response without the headers Express sent. Open Application → Service Workers in DevTools and unregister the worker temporarily to confirm whether it is intercepting the request.

Check Next.js or other frontend proxies in dev. If your frontend dev server proxies API calls (Vite proxy, next.config.js rewrites, Webpack devServer proxy), the request is same-origin from the browser’s perspective and CORS is not enforced — until you deploy. Test against the real backend origin during development so you catch CORS bugs before production.

For CORS errors specific to credentials and cookies, see Fix: CORS Credentials Error. For Next.js CORS quirks with API routes and server actions, see Fix: Next.js CORS error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles