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
- 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 checkThe first is a missing header on the real response. The second is a broken
OPTIONSpreflight. Different fixes. - Open DevTools → Network → failing request. Look at the response headers. Is
Access-Control-Allow-Originpresent? Does its value byte-for-byte match theOriginrequest header (scheme, host, and port)? - Run the same request as
curlwith theOriginheader 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
- No
Access-Control-Allow-Originheader at all in the response → Cause 1. Mountcorsmiddleware (or the Nginx equivalent). - Real request returns 200 in
curl, but browser shows preflight failure → Cause 2. Inspect theOPTIONSresponse status andAllow-Headers. - Browser console mentions wildcard plus credentials → Cause 3. Replace
*with an allow-list. - Same code worked yesterday on a different URL → Cause 4. Diff the
Originrequest header against theAllow-Originresponse header character by character. - Works on 200, fails on 4xx/5xx, you are on Nginx → Cause 5. Add
always. - API is third-party and you only need it in dev → Cause 6. Use a dev-server proxy and relative URLs.
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
- MDN — Cross-Origin Resource Sharing (CORS)
- WHATWG Fetch — CORS protocol (the normative spec)
- Express —
corsmiddleware - Nginx —
add_headerdirective - Vite —
server.proxyoptions
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.