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
- Express 4.x or 5.x, with
cors@^2.8.5installed if you intend to use the package. - Only one layer is responsible for CORS. If Nginx, an ALB or a CDN in front of Node already emits
Access-Control-Allow-Origin, adding a second copy in Express produces a response the browser rejects for having multiple values. - The exact frontend origins are known.
https://app.novaledger.devin production,http://localhost:5173locally. - You can restart the service and issue
curlrequests against it directly, bypassing the proxy, so you can attribute each header to a layer.
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.
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.
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
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.