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 Wall Between Your API and Your Frontend

I have shipped CORS bugs to production more times than I want to count, and every time the root cause was different. CORS is one of those topics where the spec is straightforward but the implementation reality is that every framework wraps it differently, every browser enforces it slightly differently, and every reverse proxy has its own opinion about preflight responses. The error message is usually clear; finding the layer that produces it is the part that takes hours. 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.

Quick Reference Before You Dive In

If you arrived here from Google with a CORS error in your console, the five facts that resolve roughly 90 percent of the cases I have triaged:

  1. CORS is enforced by the browser, not the server. The request reaches your Express handler, the handler runs, and the response is sent. The browser then discards the response because the headers do not satisfy CORS rules. The canonical reference is the Fetch standard CORS section; the practical reference is MDN’s CORS guide.
  2. app.use(cors()) must be registered BEFORE any route. The single most common Express CORS mistake. Express middleware runs in order, so any route declared earlier never gets the CORS headers attached.
  3. Access-Control-Allow-Origin: * cannot be combined with credentials: 'include'. When you send cookies, the origin list must be explicit. This is a security invariant the browser enforces because the alternative would let any site impersonate the user against your API.
  4. Preflight OPTIONS requests need a 200 / 204 response. Non-simple requests (custom headers, PUT/DELETE, JSON content-type) trigger a preflight. If Express returns 404 for OPTIONS, every non-simple request fails. The cors npm package handles this automatically when applied globally.
  5. A reverse proxy can add, strip, or duplicate the headers. If local development works but production fails, the bug is in nginx / Cloudflare / API Gateway, not Express. Use curl -i from outside the network to inspect what actually reaches the browser.

The rest of this article walks through each of those in detail, plus the failure modes most other guides skip.

Where CORS Actually Lives

I personally find the most useful framing is to remember that 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 order: cors() 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 credentials: Access-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.

When to Use Which Fix

The next seven sections cover the fixes in detail. The table below maps your symptom to the specific fix I would reach for first.

Your symptomRecommended fixWhy
No Access-Control-Allow-Origin header at all on responsesFix 1: install cors and apply app.use(cors()) before routesMost common: middleware missing or ordered wrong
Header present but uses * and you need cookiesFix 2: configure specific origins, NOT wildcardBrowser rejects wildcard + credentials combination
Preflight OPTIONS returns 404Fix 3: ensure cors() is global, or add explicit OPTIONS handlerPer-route cors needs explicit OPTIONS coverage
credentials: 'include' request fails with credentials errorFix 4: set credentials: true AND specific originTwo-part requirement, both halves needed
Successful responses get CORS but errors do notFix 5: apply cors before error handler, or reapply inside itError handlers can bypass middleware chain
Duplicate Access-Control-Allow-Origin headerFix 6: pick ONE layer (Express OR nginx), never bothMost reverse proxies pass through Express headers
Cannot tell what headers are actually being sentFix 7: curl -I -X OPTIONS from outside, log req.headers.originBrowser network tab + curl diff isolates the layer

If multiple rows look like they apply, pick the topmost one.

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);

A specific failure mode I keep seeing in PR reviews: someone calls app.use(cors()) at the bottom of the file, after all the routes have already been registered. Express middleware runs strictly in order, so any route declared earlier never gets the CORS headers attached. The fix is to register CORS as one of the first things in the app, before any route registration. I have started keeping a comment block at the top of every Express app I write that calls out the required middleware order, because this mistake is too easy to make under deadline pressure.

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,
}));

The combination is not arbitrary. The * wildcard means “any origin can read this response,” and allowing credentials from any origin would let any website make authenticated requests on behalf of your users. I treat this as a hard security invariant when I review CORS configs: if credentials: true is set, the origin list must be explicit, not a wildcard. Browsers enforce this as an error because it is the most likely shape of a real vulnerability.

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.

CORS Failure Modes I Have Personally Tracked Down

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.

What Other Tutorials Get Wrong About Express CORS

Most CORS tutorials list the same fixes but frame them in ways that produce subtle bugs. The gaps I see most often:

They recommend setting Access-Control-Allow-Origin: * as the universal fix. This works only until you add credentials. The * wildcard is incompatible with credentials: 'include', and most production apps eventually need cookies, auth headers, or session storage. Articles that recommend the wildcard set readers up for a second migration the moment authentication is added.

They show app.use(cors()) without addressing middleware order. The single most common Express CORS bug is calling app.use(cors()) after the routes. Tutorials that show the call in isolation (without the surrounding route registrations) hide the most likely cause.

They omit the credentials + specific-origin requirement. Browsers reject Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true as a security invariant. Tutorials that show cors({ credentials: true }) without specifying origin: produce configurations that look correct in code review but fail at runtime.

They miss the preflight cache invalidation problem. Access-Control-Max-Age caches preflight responses in the browser. If you change Access-Control-Allow-Headers and the browser has a cached preflight, your fix appears to do nothing until the cache expires. Tutorials that recommend setting Max-Age high (24 hours+) for “performance” leave readers debugging stale preflights.

They confuse CORS with same-site cookies. SameSite=Lax and SameSite=Strict block cookies on cross-origin requests regardless of CORS headers. A fully correct CORS config still fails for credentialed requests if the cookie has SameSite=Lax and the request is cross-site. Many tutorials show one but not the other.

They skip the reverse-proxy layer. Articles written from the Express-only perspective miss that nginx, Cloudflare, API Gateway, and AWS ALBs all manipulate response headers. The bug “works locally, fails in production” is almost always a proxy layer, but tutorials that focus on app.use(cors()) send readers chasing Express-only fixes.

Frequently Asked Questions

Why does CORS fail in the browser but work with curl?

CORS is a browser-side security policy. The server sends the same response to curl, Postman, and the browser; only the browser checks the CORS headers before letting JavaScript read the response body. Tools like curl ignore CORS entirely because they are not running untrusted JavaScript from arbitrary origins. The “works in curl” observation confirms the server is responding correctly but the headers are missing or wrong.

Why can I not use * with credentials?

The wildcard * means “any origin can read this response.” Combined with credentials: 'include' (which sends cookies and auth headers), this would let any website make authenticated requests on behalf of the user against your API. The browser blocks the combination as a CORS error to prevent the security hole. The fix is to specify the exact allowed origins, never *, when credentials are involved.

What is a preflight request and when does the browser send one?

Preflight is an OPTIONS request the browser sends before “non-simple” requests to check whether the server allows them. The browser considers a request simple when it uses GET, HEAD, or POST with one of three Content-Type values (text/plain, application/x-www-form-urlencoded, multipart/form-data) and no custom headers. Anything else, including Content-Type: application/json or any custom header like Authorization, triggers a preflight. The full rules live in the Fetch standard.

Should I configure CORS in Express or in nginx?

Pick one. Configuring CORS in both layers almost always produces duplicate Access-Control-Allow-Origin headers, which the browser rejects. The pragmatic answer is to configure it in Express when the logic is dynamic (different origins for different routes, conditional whitelisting), and in nginx when it is static (one origin, same for everything). The one place I do recommend nginx-side handling is when Express is behind a CDN that caches responses, because dynamic origins in cached responses are a separate problem.

Why does Safari behave differently than Chrome on CORS?

Safari’s CORS implementation is stricter than Chrome’s in two ways. First, it enforces SameSite cookie policies more aggressively on cross-origin requests, so credentialed requests that work in Chrome can fail in Safari without any console warning. Second, Safari logs CORS errors in the Network tab rather than the Console, which makes them easier to miss. Test against Safari explicitly before shipping; the “works in Chrome” baseline is not sufficient.

Does the cors package handle OPTIONS preflight automatically?

Yes, when you apply it globally with app.use(cors()). The middleware intercepts OPTIONS requests, sets the appropriate Access-Control-* headers, and returns 204. If you apply cors per-route via app.get('/api/x', cors(opts), handler), you must also register app.options('/api/x', cors(opts)) for each path, or use app.options('*', cors(opts)) to catch all OPTIONS requests globally. The global pattern is simpler and the one I use by default.

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