JavaScript Fetch CORS: How It Works and How to Fix CORS Errors

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
11 min read
Published Sep 25, 2024
Updated Oct 9, 2026
JavaScript Fetch CORS blog banner image
15 min read
1,621 words
24 sections11 code blocks

You call fetch(), and the console says the request was blocked by CORS policy. The same URL works in Postman, curl and the browser's address bar. What's going on?

The short version: CORS is enforced by the browser and fixed on the server. No fetch option can make a blocked request succeed. This guide explains how CORS works, shows server code that fixes it in Express and Next.js, and covers preflight requests, cookies, mode: "no-cors" and what to do when you don't control the API.

Tested with: Chrome 152, Node.js 20, Express 5.2, Vite 8.3 (October 2026). The error messages below are copied from Chrome's console during those tests.

Quick Fix: Which Case Are You In?

SituationWhat to do
You own the APISend CORS headers from the server (Express or Next.js code below)
You don't own the APICall it from your own server, or use a dev proxy while developing
You added mode: "no-cors"Remove it: you get an empty, unreadable response
You send cookies or authUse credentials: "include" and return the exact origin, never *

What Is CORS?

Browsers follow the same-origin policy: JavaScript on one origin can't read responses from another origin. An origin is the scheme, host and port together, so all of these count as different origins:

PageRequest toSame origin?
http://localhost:5500http://localhost:4000No: different port
http://localhost:5500http://127.0.0.1:5500No: different host
http://example.comhttps://example.comNo: different scheme
https://www.example.comhttps://api.example.comNo: different subdomain
https://example.com/bloghttps://example.com/apiYes: only the path differs

CORS (Cross-Origin Resource Sharing) lets a server relax that rule. The browser adds an Origin header to cross-origin requests. If the response carries a matching Access-Control-Allow-Origin header, your code gets the response. If not, the browser blocks it and fetch rejects.

That's why Postman and curl always work: they aren't browsers and don't apply CORS at all.

CORS doesn't protect your server. In our test, a server with no CORS headers still received and ran every simple GET; the browser only hid the response from the page. Protect your API with authentication, and treat CORS as a rule about which websites may read responses in a browser.

Simple Requests vs Preflight Requests

For a simple request (GET, HEAD or POST with only basic headers), the browser sends the request straight away and checks the response headers. For anything else, it first sends a preflight: an OPTIONS request asking whether the real request is allowed. A request needs a preflight when it:

  • uses another method, such as PUT, PATCH or DELETE;
  • sets a header outside the basic set, such as Authorization;
  • sends a Content-Type other than text/plain, multipart/form-data or application/x-www-form-urlencoded. That includes application/json.
JavaScript
// Simple request: one GET, no preflight
await fetch("http://localhost:4000/api/posts");

// Same GET with a JSON Content-Type: the browser sends OPTIONS first.
// A GET has no body, so this header does nothing useful.
await fetch("http://localhost:4000/api/posts", {
  headers: { "Content-Type": "application/json" },
});

// A JSON POST always needs a preflight, and that's fine
await fetch("http://localhost:4000/api/posts", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "From the browser" }),
});

Our test server's log showed exactly this: the plain GET arrived as one request, and the GET with a JSON Content-Type arrived as OPTIONS followed by GET. Many examples online (including an earlier version of this article) add Content-Type: application/json to every request. On a GET it only adds a round trip, and if the server doesn't handle preflights, it turns a working request into a CORS error.

Do You Need mode: "cors" in fetch?

No. "cors" is already the default mode for fetch(), so adding it changes nothing. The three modes are:

ModeWhat it does
"cors" (default)Cross-origin requests allowed if the server sends CORS headers
"same-origin"Rejects any cross-origin request
"no-cors"Sends the request but gives you an opaque response you can't read

Why mode: "no-cors" doesn't fix CORS errors

no-cors makes the error go away by hiding the response from you:

JavaScript
const res = await fetch("http://localhost:4001/none", { mode: "no-cors" });

console.log(res.type);         // "opaque"
console.log(res.status);       // 0
console.log(res.ok);           // false
console.log(await res.text()); // "" (the body is hidden from you)

The values in the comments are what Chrome returned in our test. Even res.json() fails, because the body is empty. no-cors is only useful for fire-and-forget requests where you never need the answer.

Fix CORS in Express

Add a middleware before your routes. It answers preflights and adds headers for the origins you trust:

JavaScript
import express from "express";

const app = express();
app.use(express.json());

// Only these origins (scheme + host + port) may call the API from a browser.
const ALLOWED_ORIGINS = new Set([
  "http://localhost:5500",
  "https://www.example.com",
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.set("Access-Control-Allow-Origin", origin);
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Expose-Headers", "X-Total-Count");
  }
  // The response depends on the Origin header, so caches must key on it.
  res.vary("Origin");

  // Preflight: answer it here, before any route or auth check runs.
  if (req.method === "OPTIONS") {
    res.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE");
    res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.set("Access-Control-Max-Age", "600"); // cache the preflight for 10 minutes
    return res.sendStatus(204);
  }

  next();
});

const posts = [{ id: 1, title: "Hello CORS" }];

app.get("/api/posts", (req, res) => {
  res.set("X-Total-Count", String(posts.length));
  res.json(posts);
});

app.post("/api/posts", (req, res) => {
  const post = { id: posts.length + 1, title: String(req.body?.title ?? "") };
  posts.push(post);
  res.status(201).json(post);
});

app.post("/api/login", (req, res) => {
  // Cross-site (a different domain, not just a different port) also needs
  // SameSite=None and Secure, which in turn needs HTTPS.
  res.cookie("session", "demo-user", { httpOnly: true, sameSite: "lax" });
  res.json({ ok: true });
});

app.get("/api/me", (req, res) => {
  const session = req.headers.cookie?.match(/(?:^|;\s*)session=([^;]+)/)?.[1];
  if (!session) return res.status(401).json({ error: "Not logged in" });
  res.json({ user: session });
});

app.listen(4000, () => console.log("API on http://localhost:4000"));
  • Allowlist, then echo the origin: the server returns the caller's own origin only if it's on the list. Unknown origins get no Access-Control-Allow-Origin, so the browser blocks them. When we opened the test page from http://127.0.0.1:5500 instead of localhost:5500, every request failed, as expected.
  • Vary: Origin: the response changes per origin, so CDNs and browser caches must not reuse one origin's response for another.
  • Answer OPTIONS first: the preflight carries no cookies or Authorization header. If an auth check runs before it and returns 401, the real request is never sent.
  • Allow-Headers: list every non-basic header your frontend sends, such as Content-Type for JSON and Authorization for tokens.
  • Max-Age: lets the browser cache the preflight for 600 seconds instead of sending OPTIONS before every call.
  • Expose-Headers: makes custom response headers like X-Total-Count readable from JavaScript (more below).

Or use the cors package

The cors npm package does the same in one call (npm install cors). With this config it echoed allowed origins, sent no Access-Control-Allow-Origin to an unknown origin, and answered preflights with 204:

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

const app = express();

app.use(cors({
  origin: ["http://localhost:5500", "https://www.example.com"],
  credentials: true,
  exposedHeaders: ["X-Total-Count"],
  maxAge: 600,
}));

Fix CORS in a Next.js Route Handler

If your Next.js frontend calls its own /api routes, that's same-origin and needs no CORS. You need these headers only when another site or app calls your Next.js API. In the App Router, export an OPTIONS handler for the preflight and add the same headers to every response:

JavaScript
// app/api/posts/route.js (Next.js App Router)
const ALLOWED_ORIGINS = new Set([
  "http://localhost:5500",
  "https://www.example.com",
]);

function corsHeaders(request) {
  const origin = request.headers.get("origin");
  const headers = { Vary: "Origin" };
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    headers["Access-Control-Allow-Origin"] = origin;
    headers["Access-Control-Allow-Credentials"] = "true";
  }
  return headers;
}

// Next.js calls this for the preflight request.
export async function OPTIONS(request) {
  return new Response(null, {
    status: 204,
    headers: {
      ...corsHeaders(request),
      "Access-Control-Allow-Methods": "GET, POST",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      "Access-Control-Max-Age": "600",
    },
  });
}

export async function GET(request) {
  return Response.json([{ id: 1, title: "Hello CORS" }], {
    headers: corsHeaders(request),
  });
}

export async function POST(request) {
  const { title } = await request.json();
  return Response.json(
    { id: 2, title: String(title ?? "") },
    { status: 201, headers: corsHeaders(request) },
  );
}

We ran these handlers in Node.js 20 with real Request objects: allowed origins got their origin back with a 204 preflight, an unknown origin got no CORS header, and requests without an Origin header (same-origin calls) still worked.

Sending Cookies with Cross-Origin fetch

Cross-origin fetch doesn't send or save cookies unless you set credentials: "include":

JavaScript
// Log in: the response sets an HttpOnly cookie
await fetch("http://localhost:4000/api/login", {
  method: "POST",
  credentials: "include",
});

// Without credentials: "include" the cookie isn't sent → 401
await fetch("http://localhost:4000/api/me");

// With it, the server sees the session → { user: "demo-user" }
const me = await fetch("http://localhost:4000/api/me", {
  credentials: "include",
}).then((res) => res.json());

In our test, /api/me returned 401 without credentials: "include", even right after logging in. For credentialed requests the server must also:

  • send Access-Control-Allow-Credentials: true;
  • send the exact origin in Access-Control-Allow-Origin. A wildcard * is rejected. Chrome reported: 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'.

Our test used two ports on localhost, which browsers treat as the same site. If the frontend and API are on different domains, the cookie also needs SameSite=None; Secure (so HTTPS), and some browsers block third-party cookies anyway. Putting the API on a subdomain of the site, or behind a proxy on the same domain, avoids that. See our cookies guide for cookie attributes.

Reading Response Headers

Even when CORS allows the request, JavaScript can only read a few basic response headers, such as Content-Type. Others come back as null until the server lists them in Access-Control-Expose-Headers:

JavaScript
const res = await fetch("http://localhost:4000/api/posts");

res.headers.get("Content-Type");  // "application/json; charset=utf-8"
res.headers.get("X-Total-Count"); // "2", only because the server exposes it
res.headers.get("X-Powered-By");  // null: sent by Express, but not exposed

Handling CORS Errors in JavaScript

When CORS blocks a response, fetch rejects with a TypeError (Failed to fetch in Chrome). A network failure or a wrong hostname rejects with the same error, and the browser doesn't tell your code which one it was; the reason is only in the console. A 404 or 500, on the other hand, doesn't reject at all. This helper handles both cases:

JavaScript
// fetch() rejects only when no response can be read: a CORS block, a network
// failure or a bad hostname. JavaScript gets a bare TypeError either way; the
// browser prints the real reason in the console.
export async function requestJSON(url, options = {}) {
  let res;
  try {
    res = await fetch(url, options);
  } catch (err) {
    throw new Error(
      `No response from ${url}. If the console mentions "CORS policy", the server must allow this origin.`,
      { cause: err },
    );
  }

  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText} from ${url}`);
  }
  return res.json();
}
JavaScript
import { requestJSON } from "./request.js";

try {
  const posts = await requestJSON("http://localhost:4000/api/posts");
  console.log(posts);
} catch (err) {
  console.error(err.message); // what happened
  console.error(err.cause);   // TypeError: Failed to fetch (for CORS blocks)
}

For more on fetch itself (methods, JSON, timeouts, aborting), see our Fetch API guide.

CORS Error Messages and How to Fix Them

These are the messages Chrome printed in our tests, with the cause of each:

Chrome saysCauseFix
No 'Access-Control-Allow-Origin' header is present on the requested resource.The server sent no CORS headers, or your origin isn't on its listAdd your exact origin (scheme + host + port) on the server
The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*' when the request's credentials mode is 'include'.credentials: "include" with Allow-Origin: *Echo the allowed origin and send Allow-Credentials: true
Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.The preflight doesn't list a header you sendAdd it to Access-Control-Allow-Headers, or stop sending it (e.g. Content-Type on a GET)
Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header ...OPTIONS hit an auth check, a 404, or a route without CORS headersAnswer OPTIONS before auth, with the CORS headers

When You Don't Control the API

If a third-party API doesn't send CORS headers, nothing in the browser can fix that. Make the request from a server instead: same-origin requests to your own server aren't subject to CORS, and servers aren't bound by it.

In development: a Vite proxy

JavaScript
// vite.config.js — development only
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      // fetch("/api/posts") in the browser → http://localhost:4000/api/posts
      "/api": {
        target: "http://localhost:4000",
        changeOrigin: true,
      },
    },
  },
});
JavaScript
// The page runs on http://localhost:5173, so this is a same-origin request.
// Vite forwards it to http://localhost:4000/api/posts.
const posts = await fetch("/api/posts").then((res) => res.json());

In our test, the proxied request returned 200 even though the API had no CORS entry for localhost:5173, while calling http://localhost:4000 directly from the same page was blocked. The proxy runs only with vite dev; in production, put the same forwarding in your real server.

In production: your own backend

  • Add a small endpoint on your server (an Express route or a Next.js route handler) that calls the third-party API and returns the result.
  • In Next.js you can also forward a path with rewrites in next.config.js.
  • Keep API keys on the server. Anything in frontend code, including keys in fetch headers, is visible to every visitor.
  • Avoid public "CORS proxy" services in production: they see every request and response, and can go down or change without warning.

CORS Security Checklist

  • Don't reflect every Origin back while also allowing credentials. That lets any website make logged-in requests as your users and read the answers.
  • Access-Control-Allow-Origin: * is fine for public, read-only data that doesn't use cookies.
  • CORS isn't authentication. Requests from curl, scripts and servers ignore it, so check permissions on every endpoint.
  • CORS doesn't stop CSRF: a simple cross-site form POST still reaches your server. Use SameSite cookies and CSRF tokens for state-changing requests.

The MDN CORS guide has the full list of headers and rules.

FAQs

How do I fix a CORS error in fetch?

On the server: return Access-Control-Allow-Origin with your page's exact origin, answer OPTIONS preflights with the allowed methods and headers, and add Access-Control-Allow-Credentials: true if you send cookies. If you can't change the server, call the API from your own backend or a dev proxy.

Why does the request work in Postman but not in the browser?

CORS is a browser rule. Postman, curl and servers don't check Access-Control-Allow-Origin, so they always get the response.

What does fetch mode: "no-cors" do?

It sends the request but returns an opaque response: type is "opaque", status is 0 and the body is empty. It hides CORS errors rather than fixing them.

Why is the browser sending an OPTIONS request?

That's the preflight. It happens for methods other than GET, HEAD and POST, for headers like Authorization, and for Content-Type: application/json. The server must answer it with a 2xx status and the CORS headers.

Are localhost:3000 and localhost:5000 the same origin?

No. The port is part of the origin, so a frontend on port 3000 calling an API on port 5000 needs CORS headers or a dev proxy. localhost and 127.0.0.1 are different origins too.

Can I disable CORS in the browser?

Not for your users, and you shouldn't need to. Fix the headers on the server or use a proxy, so the app works for everyone and not just on your machine.

Does CORS protect my API?

No. It only controls which websites can read responses in a browser. Your server still receives requests, and non-browser clients ignore CORS entirely, so use authentication and authorization.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About Muhaymin Bin Mehmood

Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.