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 };

The two anchors in SUBDOMAIN_PATTERN are doing more work than they look. Run the same five origin strings past the anchored pattern and past the naive /yourdomain\.com/ that teams often start with, and the divergence is stark:

Anchored versus unanchored origin patterns on five candidate strings A five-row matrix. Each row is a candidate Origin header value; two columns show the verdict from an anchored regex and from an unanchored one. The two legitimate origins match both, while a protocol downgrade and two lookalike hostnames slip past only the unanchored pattern. Candidate Origin header as received by the callback anchored pattern ^https://…yourdomain\.com$ unanchored pattern /yourdomain\.com/ https://app.yourdomain.com match — allowed match — allowed https://yourdomain.com match — allowed match — allowed http://app.yourdomain.com no match — rejected match — plaintext hop https://evil-yourdomain.com no match — rejected match — prefix trick https://yourdomain.com.attacker.io no match — rejected match — suffix trick Same five strings, two patterns: only the anchored one refuses all three hostile forms.

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.

The reason this handler matters is that a preflight is an unauthenticated request travelling through an authenticated stack. Trace the same OPTIONS down both pipelines and the divergence appears at the third layer:

An OPTIONS request through the Express middleware chain, with and without a preflight handler Two vertical pipelines side by side. Both start with the same OPTIONS request and the same cors() middleware. The left pipeline continues into the auth middleware and ends in a 401 stripped of CORS headers; the right pipeline is intercepted by app.options and ends in a 204 carrying Max-Age. Without app.options() — the preflight falls through With app.options() — the preflight is answered OPTIONS /endpoint arrives, no cookie cors() sets Allow-Origin and Vary requireAuth() sees no session 401 from the auth layer, and the error renderer drops the CORS headers console reports a failed preflight OPTIONS /endpoint arrives, no cookie cors() sets Allow-Origin and Vary app.options() matches and replies 204 carrying Access-Control-Max-Age, and requireAuth() never executes browser caches the grant and proceeds

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.