~/troubleshooting/fix-cors-errors-in-javascript-express-nginx-react-proxy
§ POST · MAY 14, 2026 v1.0

Fix CORS errors in JavaScript: Express, Nginx, React proxy

Three real CORS fixes, in priority order: Express cors() middleware, Nginx headers, Vite/CRA proxy. With the diagnostics that pick the right one in 30 seconds.
Ryan CallowayStaff contributor
  8 min read

By Ryan Calloway. Updated May 2026.

“My fetch works in Postman but the browser blocks it.” Pick any month on r/learnjavascript and the same wording lands within the first page of new posts. A 2024 r/FullStack thread walks through the symptom on a Vercel-hosted Express API; a 2024 r/graphql thread shows the same root cause behind an Apollo Server. The fix is one of three lines of config; the hard part is picking which one your stack actually needs.

Quick answer

Add an Access-Control-Allow-Origin header on the server (not the client) that matches the exact origin calling the API. If the request uses cookies or custom headers, also add Access-Control-Allow-Credentials: true and make OPTIONS return 2xx. The 80%-of-the-time fix on a Node API: app.use(cors({ origin: "https://your-app.example.com", credentials: true })) mounted before any route handlers. If you do not own the API, proxy through your Vite or Next dev server.

How to diagnose in 30 seconds

  1. Read the message. The two shapes you will see:
    blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
    blocked by CORS policy: Response to preflight request doesn't pass access control check

    The first is a missing header on the real response. The second is a broken OPTIONS preflight. Different fixes.

  2. Open DevTools → Network → failing request. Look at the response headers. Is Access-Control-Allow-Origin present? Does its value byte-for-byte match the Origin request header (scheme, host, and port)?
  3. Run the same request as curl with the Origin header forced. What the server returns here is what the browser sees. No guesswork.
    curl -i -X OPTIONS \
      -H "Origin: https://app.example.com" \
      -H "Access-Control-Request-Method: POST" \
      https://api.example.com/users

Whichever of the two error shapes you have, drop into the matching cause below.

Cause 1: the API never sets Access-Control-Allow-Origin

The default. A bare Express, Fastify, or Apollo server without CORS middleware sends no Allow-Origin header, so any cross-origin browser fetch is blocked.

What it looks like:

// Express 5.0+ (April 2025 stable) or Express 4.21+
import express from "express";
const app = express();
app.get("/users", (req, res) => res.json([{ id: 1 }]));
app.listen(3000);
// Browser: blocked by CORS policy: No 'Access-Control-Allow-Origin' header

Fix — install and mount the official cors package before route handlers:

npm install cors

import express from "express";
import cors from "cors";

const app = express();

app.use(cors({
  origin: ["https://app.example.com", "http://localhost:5173"],
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
}));

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

Express 5 changed wildcard route matching (app.options("*", ...) is now app.options("/{*splat}", ...)) but the cors middleware itself is unchanged: mount it as global middleware and it handles preflight for every route. The Express cors middleware docs are the authoritative reference.

Cause 2: OPTIONS preflight returns 4xx

When the browser sends a request with custom headers, non-simple methods (PUT, DELETE, PATCH), or Content-Type: application/json, it fires an OPTIONS request first to ask permission. If that returns 404, 401, or 405, the real request never fires and you get the “preflight doesn’t pass access control check” error. The r/FullStack thread above hit this exactly: their auth middleware ran before CORS, so OPTIONS returned 401.

What it looks like:

app.use(authMiddleware);     // returns 401 on no token, including for OPTIONS
app.use(cors());             // never runs for the preflight
app.post("/users", ...);

Fix — CORS goes first, auth goes second:

app.use(cors({ origin: "https://app.example.com", credentials: true }));
app.use(authMiddleware);
app.post("/users", ...);

If you are behind a serverless platform (Vercel, Lambda) where you cannot reorder middleware easily, hand-handle OPTIONS at the top of the handler and return 204 with the right headers. The MDN preflighted requests reference lists which request shapes trigger preflight.

Cause 3: origin: "*" with credentials: true

The browser refuses this combination, full stop. Per the Fetch spec, when credentials is "include" on the client, the server’s Access-Control-Allow-Origin must be an exact origin, never *.

What it looks like:

app.use(cors({ origin: "*", credentials: true }));
// Browser: 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'.

Fix — reflect the request origin (validated against an allow-list) instead of *:

const ALLOWED = new Set([
  "https://app.example.com",
  "https://staging.example.com",
  "http://localhost:5173",
]);

app.use(cors({
  origin: (origin, cb) => cb(null, !origin || ALLOWED.has(origin) ? origin : false),
  credentials: true,
}));

Echoing the origin sounds permissive but only echoes origins on your allow-list; everything else gets false, which omits the header.

Cause 4: origin mismatch you did not see

The browser compares scheme, host, and port byte-for-byte. http://localhost:5173, http://127.0.0.1:5173, and https://localhost:5173 are three different origins. Same for www.example.com vs example.com, and any port change.

What it looks like:

app.use(cors({ origin: "http://localhost:5173" }));
// You opened the app at http://127.0.0.1:5173 -> blocked.

Fix — either canonicalize what you visit, or list both during dev:

app.use(cors({
  origin: /^http(s)?:\/\/(localhost|127\.0\.0\.1)(:[0-9]{1,5})?$/,
}));

The regex covers every local port without listing them. Use it during dev, replace with explicit origins in prod.

Cause 5: Nginx drops CORS headers on errors

If your API is behind Nginx and CORS works on 200s but breaks the moment the API returns a 404 or 500, you forgot the always flag. Without it, Nginx only writes add_header directives on 2xx and 3xx responses.

What it looks like:

location /api/ {
  add_header Access-Control-Allow-Origin "https://app.example.com";
  proxy_pass http://127.0.0.1:3000;
}
// 200 -> CORS header present, request succeeds.
// 500 -> CORS header missing, browser shows a CORS error instead of the real 500.

Fix — add always on every CORS header, and handle OPTIONS at the Nginx layer:

location /api/ {
  if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Content-Length 0;
    add_header Content-Type text/plain;
    return 204;
  }

  add_header Access-Control-Allow-Origin "https://app.example.com" always;
  add_header Access-Control-Allow-Credentials "true" always;
  proxy_pass http://127.0.0.1:3000;
}

The always flag is non-optional. The add_header reference documents this exact behavior.

Cause 6: dev-server proxy bypassed by an absolute URL

If the API is someone else’s (Stripe, your legacy service, a staging server you cannot redeploy today), the cleanest dev-only fix is to proxy through your dev server. The browser sees one origin, the proxy forwards server-side where CORS does not apply.

What it looks like — you set up the Vite or Next proxy correctly but kept hard-coding the absolute API URL:

// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
  server: {
    proxy: {
      "/api": {
        target: "https://api.example.com",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
});

// In your component (the bug):
fetch("https://api.example.com/users");   // bypasses the proxy, CORS fires

Fix — use a relative path so the dev server intercepts it:

fetch("/api/users");   // hits Vite, gets proxied to api.example.com

For Next.js (App Router), the equivalent is rewrites() in next.config.js:

// next.config.js
module.exports = {
  async rewrites() {
    return [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }];
  },
};

This is a development-only fix. In production you still need Cause 1 / 5 fixed on the real server. The Vite server-proxy docs and Next.js rewrites docs have the full options.

Decision tree: which cause is yours

Thirty seconds of curl beats thirty minutes of console spelunking, every time. Once the curl response shows the right headers, the browser will accept it.

FAQ

How do I fix CORS in Express.js?

Install the cors package, mount it before any route handlers, and pass the exact browser origin (not *) with credentials: true if you send cookies. See Cause 1 above for the full middleware call. The cors package works identically on Express 4.21 and Express 5.

CORS error on a React app fetching a Node.js API, what do I change?

Change the server, not the client. The React app cannot set CORS headers; only the API can. Mount cors middleware in Express, or, in development, proxy through Vite or Next to sidestep the preflight entirely.

How do I allow all origins in CORS safely?

You usually do not. origin: "*" works only for requests without credentials; the browser rejects it for anything with cookies or Authorization. For a public read-only API with no auth, * is fine. For anything else, list origins explicitly.

Why does Postman work but the browser fails?

Postman does not enforce CORS. It is a server-to-server client, not a browser. A successful Postman request proves the API works; it does not prove the browser will let your app use it. CORS rules only apply in browsers.

Do I need CORS headers on same-origin requests?

No. CORS is strictly cross-origin. app.example.com calling api.example.com is cross-origin (different subdomains), so CORS applies. example.com/api on the same host is same-origin, no CORS involved.

My * origin worked on one endpoint but not another. Why?

Either one endpoint has credentials: true and the other does not, or your framework writes the header on some routes and not others. The browser is consistent; your server is not. Audit every route that returns CORS headers.

Sources and further reading

Next steps

If the CORS fix uncovered a broader API-contract mess, the REST API vs GraphQL guide covers when a new endpoint shape solves the problem for good. If you landed here after chasing a related JavaScript TypeError, the Cannot read properties of undefined fix is the next most common one I debug alongside CORS.

esc