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.js dynamic origin allowlist validation flow A flow diagram showing how an incoming browser request passes through the Express CORS middleware: Origin header is extracted, checked against the allowlist, reflected if matched, or rejected with an error if not matched, before reaching the route handler. Browser Origin: https://… OPTIONS cors() middleware extract Origin header call origin callback in allowlist? yes Reflect origin ACAO = request Origin Vary: Origin → next() 204 no callback(Error) Express → 500 / no ACAO header Browser blocks the request

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.