Express cors Package vs Manual CORS Headers

Failure symptom:

Access to fetch at 'https://api.novaledger.dev/v1/ledgers' from origin
'https://app.novaledger.dev' has been blocked by CORS policy: Request header
field x-tenant-id is not allowed by Access-Control-Allow-Headers in preflight
response.

A frontend team added one request header and the API stopped answering. Nothing on the server changed. This page sits under Framework CORS Middleware Configuration and answers the question that error always provokes on an Express service: should the CORS policy be a middleware package, or twenty lines you own?

Root Cause

A hand-written CORS block almost always freezes its header list. Someone writes res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization") on the day the API ships, and the list is correct for exactly as long as the frontend sends those two headers. The preflight algorithm in the WHATWG Fetch Standard compares every entry in Access-Control-Request-Headers against Access-Control-Allow-Headers and fails the whole request if any one of them is missing, so adding X-Tenant-ID in the client breaks a server nobody touched. The cors package avoids that specific trap by reflecting the requested header list when allowedHeaders is not configured — but the same package will, with equal cheerfulness, ship an open policy if you accept its other defaults. Neither approach is safe by construction; what matters is knowing exactly which headers end up on the wire, a discipline laid out in Access-Control-* Header Directives.

Prerequisite State

What cors() Actually Sets

The single most useful fact about the package is its default option set, because “it worked in development” almost always means “the defaults happened to be enough”.

Option Default Header written
origin "*" Access-Control-Allow-Origin
methods "GET,HEAD,PUT,PATCH,POST,DELETE" Access-Control-Allow-Methods (preflight only)
allowedHeaders unset — reflects Access-Control-Request-Headers Access-Control-Allow-Headers (preflight only)
exposedHeaders unset Access-Control-Expose-Headers — nothing readable by default
credentials unset Access-Control-Allow-Credentials — never sent unless true
maxAge unset Access-Control-Max-Age — no preflight caching at all
preflightContinue false none; controls whether OPTIONS is ended here
optionsSuccessStatus 204 status of the terminated preflight

Two omissions in that table are the ones that bite. Without maxAge, every non-simple call preflights again, which the measurements in Measuring CORS Preflight Latency in Production put at a full extra round trip. Without exposedHeaders, your pagination or request-id headers exist on the wire and are simply invisible to fetch.

What bare cors() covers and what it leaves to you A two-by-two grid crossing the preflight response and the actual response with headers written automatically and headers that stay absent unless configured. The automatic cells hold the origin, methods and header reflection; the silent cells hold credentials, Max-Age and Expose-Headers. on the preflight (OPTIONS) on the actual response written by a bare app.use(cors()) Allow-Origin: * Allow-Methods: 6 verbs Allow-Headers: reflected 204, Content-Length: 0 Allow-Origin: * Vary: Origin, but only once origin is dynamic absent until you pass an options object Max-Age (no caching) Allow-Credentials a narrowed method list Allow-Credentials Expose-Headers an origin allowlist Everything in the lower row is silence, not a default value — the header simply never appears on the wire

Step-by-Step

Step 1 — Configure the package instead of accepting it

const express = require("express");
const cors = require("cors");

const app = express();

const ALLOWED_ORIGINS = ["https://app.novaledger.dev", "http://localhost:5173"];

app.use(
  cors({
    origin(origin, callback) {
      // No Origin header at all: same-origin call, curl, or a health check.
      if (!origin) return callback(null, false);
      callback(null, ALLOWED_ORIGINS.includes(origin));
    },
    credentials: true,
    methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
    allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID"],
    exposedHeaders: ["X-Request-Id", "X-Total-Count"],
    maxAge: 600,
  })
);

Passing a function rather than an array is what makes the package worth its dependency: it sets Vary: Origin for you, echoes the matched value exactly, and quietly omits every Access-Control header when the callback answers false. Mount it before any route, and before any authentication middleware — otherwise the credential-free preflight meets the auth layer first.

Step 2 — Make sure something ends the OPTIONS request

preflightContinue decides whether the middleware terminates the preflight or hands it to your router. The default, false, terminates it. Setting it to true is a deliberate choice that obliges you to answer OPTIONS yourself on every matching path.

Where the OPTIONS request stops An incoming preflight reaches the cors middleware, which branches on preflightContinue. With the default of false the middleware answers 204 immediately. With true the request continues into the router, which returns 404 unless an explicit OPTIONS handler exists. OPTIONS preflight reaches Express cors() branches on preflightContinue false — the default the middleware replies 204 with Content-Length: 0 and stops true — next() is called the router must answer OPTIONS; with no handler it 404s A 404 preflight and a missing header produce the same console message, which is why this branch is hard to spot

If you do delegate, register the handler explicitly. Express 5 changed its path syntax, so the familiar wildcard string no longer parses:

// Express 4
app.options("*", cors(corsOptions));

// Express 5 — a bare "*" is no longer a valid path
app.options(/.*/, cors(corsOptions));

Step 3 — The manual equivalent, written correctly

If you would rather own the code than the dependency, this is the whole of it. It behaves like the configured cors() call above.

const ALLOWED_ORIGINS = new Set([
  "https://app.novaledger.dev",
  "http://localhost:5173",
]);

app.use((req, res, next) => {
  const origin = req.get("Origin");

  // Tell every cache the answer depends on the request origin,
  // even when this particular origin is rejected.
  res.vary("Origin");

  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Expose-Headers", "X-Request-Id, X-Total-Count");
  }

  if (req.method !== "OPTIONS") return next();

  // Preflight: answer here, never let it reach the router.
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID");
  res.setHeader("Access-Control-Max-Age", "600");
  return res.status(204).end();
});

Three details separate this from the version that breaks in production. res.vary("Origin") appends to any existing Vary header instead of overwriting the one your compression or caching layer set — the consequences of losing it are covered in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation. The origin echo is gated on an exact set membership test, never on the mere presence of the header. And the preflight returns before next(), so no authentication or body parser ever inspects a request that carries no credentials by design.

Step 4 — Choose deliberately

Package, bare package, or hand-written Three questions decide the approach: whether another layer already writes the headers, whether credentials or dynamic origins are needed, and whether the API is public and read-only. Each question routes to one recommendation. Is a proxy, gateway or CDN in front of Express already writing Access-Control headers? yes: add nothing in Express — two copies of the header are rejected outright no Do you need credentials, a dynamic allowlist, exposed headers or a Max-Age? yes: cors() with an explicit options object — this is where the package earns its keep no Is the resource genuinely public, read-only and free of per-user data? yes: a bare app.use(cors()) is honest — the wildcard already forbids credentials no Anything left over is a private API with an unusual shape: hand-write the twenty lines, review them like security code, and cover them with a test that probes the endpoint from an origin you do not trust

Verification

# 1) Preflight with the header that used to fail
curl -sS -i -X OPTIONS https://api.novaledger.dev/v1/ledgers \
  -H 'Origin: https://app.novaledger.dev' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,x-tenant-id' \
  | grep -iE '^(HTTP|access-control|vary)'

# 2) Actual response: the echo, the credentials flag and the exposed headers
curl -sS -i https://api.novaledger.dev/v1/ledgers \
  -H 'Origin: https://app.novaledger.dev' | grep -iE '^(access-control|vary)'

# 3) The probe that separates an allowlist from origin: true
curl -sS -i https://api.novaledger.dev/v1/ledgers \
  -H 'Origin: https://novaledger.dev.attacker.example' | grep -ci access-control-allow-origin

Security Boundary Note

origin: true is the single most dangerous value in the package’s option set, precisely because it is indistinguishable from a correct configuration when you test from your own site. It instructs the middleware to reflect whichever Origin arrived, so DevTools shows a perfect exact-match echo on every request you make — while every other website on the internet receives the same courtesy. Add credentials: true and any page a signed-in user visits can read their ledger data. The same objection applies to a hand-written block that echoes req.headers.origin without a membership test. Always compare against a fixed set, and keep a probe from an untrusted origin in your test suite; the reasoning is expanded in Wildcard vs Dynamic Origin Reflection: When to Use Each.

Common Mistakes

Issue Technical impact Mitigation
Hard-coded Access-Control-Allow-Headers in a manual block Any new client header fails the preflight comparison and blocks the request Reflect Access-Control-Request-Headers, or keep the list in one constant the frontend team can amend
preflightContinue: true with no OPTIONS route The router answers the preflight 404, which the console reports as a generic CORS block Leave the default false, or register an OPTIONS handler per path
origin: true shipped as an allowlist Every origin is authorised; with credentials the API leaks authenticated data Pass an array or a validating function and probe with an untrusted origin
app.use(cors()) mounted after the auth middleware The preflight carries no credentials, gets a 401, and never receives CORS headers Mount the CORS layer first, ahead of every route and guard

FAQ

Does app.use(cors()) with no options give me a working CORS policy?

It gives you a working policy only for public, credential-free endpoints. The bare call sets Access-Control-Allow-Origin to a star, allows six methods, reflects whatever request headers the preflight asked about, and answers OPTIONS with 204. It never sets Access-Control-Allow-Credentials, never sets Access-Control-Max-Age, and never exposes a response header to JavaScript. Any API that reads cookies or custom response headers needs an options object.

Why does my OPTIONS request return 404 after I set preflightContinue?

preflightContinue: true tells the middleware to attach the headers and then call next() instead of ending the response. Control passes to your router, and unless you registered a handler for OPTIONS on that path, Express falls through to the 404 handler. The browser sees a non-2xx preflight and reports a CORS failure. Either leave preflightContinue at its default of false, or register an explicit OPTIONS handler for every path the middleware now delegates.

Is origin: true the same as an allowlist?

No, and the difference is invisible from your own frontend. origin: true tells the middleware to echo whatever Origin header arrived, so every response looks perfectly correct in your DevTools while every website on the internet is equally authorised. Combined with credentials: true it lets any page a signed-in user visits read their data. Pass an array of exact origin strings or a validating function instead, and probe the endpoint with an untrusted Origin to prove the difference.