Safeguard
AppSec

Why We Use CORS in Node.js: Configuration Without the Foot-Guns

Understanding why we use CORS in Node.js starts with what it is not: CORS is a browser relaxation mechanism, not a security wall. Here is how to configure it in Express without the classic misconfigurations.

Aisha Rahman
Security Analyst
7 min read

The reason why we use CORS in Node.js is that browsers block cross-origin JavaScript from reading responses by default, and CORS headers are how your server selectively lifts that block for origins it trusts. That framing matters: CORS does not add protection to your API — the browser's same-origin policy is the protection, and CORS is the controlled hole you punch in it. Most CORS security incidents are not missing headers; they are servers that punched the hole too wide. Let's build the mental model first, then configure a Node.js CORS setup that will not come back to bite you.

The Same-Origin Policy, in One Minute

An origin is the triple of scheme, host, and port. https://app.example.com and https://api.example.com are different origins; so are http://localhost:3000 and http://localhost:8080.

By default, a script running on https://app.example.com can send many kinds of requests to https://api.example.com, but it cannot read the responses. This is the same-origin policy, and it is what stops evil.example from silently calling your bank's API with your logged-in cookies and reading your balance out of the response.

The moment you split your frontend and API across origins — a React app on one domain, an Express API on another — legitimate requests hit the same wall. That is the entire problem CORS solves.

What the Headers Actually Negotiate

When cross-origin JavaScript makes a request, the browser attaches an Origin header. Your server replies with headers describing what it permits:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

For requests that could have side effects the browser did not historically allow — a PUT, a JSON Content-Type, an Authorization header — the browser first sends a preflight: an OPTIONS request asking permission before the real request goes out. If the preflight response does not approve the method and headers, the real request never happens.

Two consequences worth internalizing:

  • CORS is enforced by the browser. curl, server-to-server calls, and attackers with their own HTTP clients ignore it completely. If an endpoint is only "protected" by CORS, it is not protected.
  • A blocked response is often still executed server-side. For simple requests (form posts, GETs), the browser sends the request and merely hides the response. CORS never prevented the write.

Configuring the cors Middleware in Express

The standard tool is the cors package for the cors node middleware setup:

npm install cors

A configuration that is explicit about every trust decision:

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

const app = express();

const allowlist = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use(cors({
  origin(origin, callback) {
    // Non-browser clients (no Origin header) pass through;
    // CORS is irrelevant to them anyway.
    if (!origin) return callback(null, false);
    callback(null, allowlist.has(origin));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  maxAge: 600,
}));

Key choices: an exact-match allowlist (a Set, not a regex), explicit methods and headers rather than reflecting whatever was asked for, and a modest maxAge so preflight results are cached without pinning stale policy in browsers for a day.

The Classic Misconfigurations

Reflecting the Origin header

// Do not do this
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");

This says "every origin on the internet may make credentialed requests and read the responses." Any page the user visits can now act as them against your API. It is the single most common CORS vulnerability found in the wild, and it usually originates as a quick fix for a dev-environment error. Note that origin: true in the cors middleware does exactly this reflection — it is a development convenience, not a production setting.

Wildcard with credentials

Access-Control-Allow-Origin: * combined with credentials is refused by browsers outright — which pushes developers toward the reflection anti-pattern above to "make it work." If your API is truly public and unauthenticated (no cookies, no per-user data), the wildcard alone is fine. The moment credentials enter, you need an allowlist.

Regex and suffix-match origin checks

// Bypassable: matches https://app.example.com.attacker.io
if (origin.startsWith("https://app.example.com")) allow();
// Bypassable: unescaped dot matches app-example.com
const ok = /https:\/\/app.example.com/.test(origin);

Origin validation must be exact string comparison against a finite list. Every clever pattern is a future bypass, and this class of flaw is reliably findable by scanners — a DAST scan that probes your endpoints with hostile Origin values will flag reflection and weak-match policies in minutes.

Trusting the null origin

Sandboxed iframes and some redirect flows produce Origin: null. Allowing the literal string "null" allows content an attacker can host in a sandboxed iframe. Never add it to an allowlist.

Leaving localhost in production config

http://localhost:3000 in a production allowlist means any process on any user's machine that can bind that port can interact with your API through a victim's browser. Split configuration by environment.

CORS Is Not Your CSRF Defense (Mostly)

Because CORS blocks reading rather than sending, a cross-origin form post with cookies attached still executes. Rely on SameSite=Lax or Strict cookies, CSRF tokens for anything state-changing, and verify Content-Type server-side. Preflights protect JSON APIs somewhat — a Content-Type: application/json request triggers preflight — but only if you never accept the same body parsed from a form-encoded fallback.

The layered posture: CORS controls response visibility, CSRF defenses control state changes, and authentication controls who can do anything at all. Weakening any one because another exists is how audit findings happen. If you want a structured walkthrough of these browser security boundaries, the Safeguard Academy covers same-origin policy and CSRF as a connected unit.

Debugging Without Weakening

When a CORS error appears in the console, resist the reflex to broaden policy until the error disappears. Instead:

  1. Read the browser's specific complaint — it names the missing or mismatched header.
  2. Check whether the failure is the preflight (look for the OPTIONS request in the network tab) or the main request.
  3. Confirm the server actually sends CORS headers on error responses too; many frameworks skip middleware on 500s, which masquerades as a CORS failure.
  4. For local development, add http://localhost:5173 (or your dev port) to the development allowlist explicitly — not origin: true globally.

FAQ

Why do we use CORS in Node.js at all — can't we just disable it?

There is nothing to disable on the server: the browser enforces the same-origin policy, and CORS headers are your server's way of relaxing it for chosen origins. "Disabling CORS" in practice means sending permissive headers, which is exactly the misconfiguration attackers look for.

Does CORS protect my API from attackers?

No. It only governs what in-browser JavaScript on other origins can read. Anyone with curl or a script ignores CORS entirely. Authentication, authorization, and rate limiting protect the API; CORS protects your users' browser sessions from hostile web pages.

Why does my request work in Postman but fail in the browser?

Postman is not a browser and does not enforce the same-origin policy. The browser failure means your server's CORS headers do not approve the calling origin, method, or headers — fix the server policy for that origin rather than bypassing the check.

Is Access-Control-Allow-Origin: * ever acceptable?

Yes, for genuinely public, unauthenticated resources — open data endpoints, public CDN assets. It becomes dangerous the moment responses depend on cookies, tokens, or the caller's identity, and browsers will reject the wildcard-plus-credentials combination anyway.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.