Express.js Dynamic Origin Allowlist: Fix Access-Control-Allow-Origin Mismatch on Preflight
This page resolves the exact browser error that appears when Express reflects the wrong origin or refuses to reflect any origin at all during a CORS preflight. It is a practical companion to Dynamic Origin Validation Patterns, which covers the broader server-side architecture including database-backed and cache-driven allowlists.
Exact Error This Page Resolves
The browser console prints one of these two messages when the dynamic origin check fails:
Access to fetch at 'https://api.yourdomain.com/endpoint' from origin
'https://app.yourdomain.com' has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header has a value
'https://other.yourdomain.com' that is not equal to the supplied origin.
Access to XMLHttpRequest at 'https://api.yourdomain.com/endpoint' from origin
'https://app.yourdomain.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Both errors share the same root cause: the server is not reflecting the exact incoming Origin value back in Access-Control-Allow-Origin.
Root Cause
The WHATWG Fetch Standard requires that when credentials: 'include' is set, the Access-Control-Allow-Origin response header must be an exact byte-for-byte match of the request Origin — a wildcard * is prohibited. If Express middleware executes out of order, if the cors() package receives an origin callback that never calls back with true, or if an upstream route handler overwrites the header, the browser receives the wrong value and blocks the request before any data is transferred. The credential sharing and security boundaries enforced by the browser allow zero tolerance for approximation here.
Prerequisite State
Before applying this fix, confirm:
- Express 4.x or 5.x is installed and the
corsnpm package (^2.8.5) is present. - You are not setting
Access-Control-Allow-Originmanually anywhere else in the codebase — either in route handlers, in a reverse proxy config, or in a framework default. Duplicate header injection is the most common source of the “multiple values” variant of this error. - You have a list of trusted origins ready (exact strings and/or patterns). If this list must come from a database or environment at runtime, load it before the server starts or implement a caching layer as described in Dynamic Origin Validation Patterns.
Step-by-Step Fix
Step 1 — Install the cors package
npm install cors
Step 2 — Define your allowlist
Load origins from environment variables so the list can change between deployments without a code change. Combine exact strings for named partners with anchored regex for subdomain wildcards.
// allowlist.js
const ALLOWED_EXACT = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(o => o.trim())
.filter(Boolean);
// Matches any subdomain of yourdomain.com over HTTPS only
const SUBDOMAIN_PATTERN = /^https:\/\/([a-z0-9-]+\.)?yourdomain\.com$/;
function isOriginAllowed(origin) {
if (!origin) return false; // reject null/undefined
if (ALLOWED_EXACT.includes(origin)) return true;
return SUBDOMAIN_PATTERN.test(origin);
}
module.exports = { isOriginAllowed };
Step 3 — Wire the cors() middleware
Pass the validation function as the origin callback. The callback signature is (origin, callback): call callback(null, true) to approve, or callback(new Error('…')) to reject. Mount the middleware before any route definitions.
const express = require('express');
const cors = require('cors');
const { isOriginAllowed } = require('./allowlist');
const app = express();
const dynamicCors = cors({
origin(origin, callback) {
// Requests with no Origin header (same-origin, server-to-server)
// pass through without a reflected header.
if (origin === undefined) return callback(null, false);
if (isOriginAllowed(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS policy'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Apply globally — must come before routes
app.use(dynamicCors);
Step 4 — Add an explicit OPTIONS handler
The cors() package sets response headers but does not automatically short-circuit OPTIONS requests at the application level. Without an explicit handler, Express passes the preflight through to your route logic, which may return a 404 or 401 before the CORS headers are sent. Add this after app.use(dynamicCors):
// Respond to all preflight OPTIONS requests immediately
app.options('*', (req, res) => {
// cors() middleware already set Access-Control-Allow-Origin and Vary.
// Set max-age here to cache preflight results for 10 minutes.
res.setHeader('Access-Control-Max-Age', '600');
res.sendStatus(204);
});
Access-Control-Max-Age: 600 is the safe maximum that Chrome and Safari both honour — see how to set Access-Control-Max-Age effectively for browser-specific limits.
Step 5 — Set the Vary header
The cors() package sets Vary: Origin automatically when it reflects a dynamic origin. Verify this in your response headers. If you are using a CDN or reverse proxy in front of Express, confirm the proxy does not strip or consolidate the Vary header — omitting Vary: Origin allows caches to serve one origin’s CORS headers to a different origin, breaking cross-origin requests silently. The handling Vary: Origin header correctly page covers CDN-specific configuration for this.
Verification
curl preflight simulation
Run this before testing from a browser. Replace the origin and endpoint with your actual values:
curl -si -X OPTIONS https://api.yourdomain.com/endpoint \
-H "Origin: https://app.yourdomain.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization" \
| grep -E "HTTP|access-control|vary"
Expected output:
HTTP/2 204
access-control-allow-origin: https://app.yourdomain.com
access-control-allow-credentials: true
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
access-control-allow-headers: Content-Type,Authorization
access-control-max-age: 600
vary: Origin
DevTools check
Security Boundary Note
Do not use origin: true in the cors() options object when credentials: true is also set. origin: true reflects whatever Origin the client sends without any validation — this turns your CORS middleware into an open relay that allows any site to make credentialed cross-origin requests to your API. Always use the explicit callback form shown in Step 3. Similarly, never fall back to origin: '*' on validation failure; the wildcard risks and mitigation page details why wildcards and credentials are mutually exclusive at the spec level.
Common Mistakes
| Mistake | What breaks | Fix |
|---|---|---|
Using origin: true with credentials: true |
Reflects any incoming Origin without validation; any site can make credentialed API calls |
Replace with the callback pattern and an explicit allowlist |
Mounting app.use(dynamicCors) after route definitions |
Routes execute before CORS headers are set; preflight returns 404 with no CORS headers | Move app.use(dynamicCors) to the top of the middleware chain, before all routes |
Omitting app.options('*') |
Express passes preflight to route handlers; GET-only routes return 404 or auth middleware returns 401 on the OPTIONS request |
Add an explicit app.options('*', cors()) or the custom handler from Step 4 |
Unanchored regex patterns like /yourdomain\.com/ |
Matches evil-yourdomain.com or yourdomain.com.attacker.io — bypasses origin isolation |
Always anchor with ^ at start and $ at end, and require https:// prefix |
FAQ
Why does my dynamic Express CORS config fail on preflight but work on actual requests?
Preflight OPTIONS requests are sent without credentials. They require explicit Access-Control-Allow-Origin reflection before the browser allows the real request. If your middleware skips OPTIONS or sets headers after route execution, the browser rejects the preflight. Actual requests only bypass this phase if they qualify as simple requests or the preflight result was previously cached.
How do I safely handle wildcard subdomains in Express CORS?
Use a compiled regex anchored at both ends: /^https:\/\/([a-z0-9-]+\.)?yourdomain\.com$/ inside the origin callback. Never combine a wildcard Access-Control-Allow-Origin with Access-Control-Allow-Credentials: true. The anchored regex ensures only authorized subdomains match — a pattern without ^ and $ can be spoofed with a crafted hostname.
What causes the Access-Control-Allow-Origin header contains multiple values error?
Multiple CORS middleware instances, overlapping route handlers, or a reverse proxy adding its own header all produce duplicate values. Browsers reject any response with more than one Access-Control-Allow-Origin. Centralize CORS into a single middleware and, if a proxy also sets the header, disable the upstream application’s CORS output or call res.removeHeader('Access-Control-Allow-Origin') before the middleware sets it.
Related
- Dynamic Origin Validation Patterns — parent page covering runtime validation architecture, database-backed allowlists, and edge caching
- Configuring CORS in Nginx for Multiple Origins — the Nginx equivalent using the
mapdirective - Handling Vary: Origin Header Correctly — CDN and proxy cache isolation via
Vary - Wildcard Risks & Mitigation — why
*is forbidden with credentials and how to migrate off it