CORS You Can Take to Production
A reflected allow-list, credentials done right, and why cors({ origin: true }) plus cookies is a footgun.
The default cors() answers every origin with Access-Control-Allow-Origin: *. That is fine for a truly public, unauthenticated API and dangerous for anything with credentials. For real apps you want a dynamic allow-list.
The credentials rule you cannot break
The Fetch spec forbids the wildcard * together with credentials: 'include'. If your frontend sends cookies, the server must echo back the exact requesting origin and set Access-Control-Allow-Credentials: true. The cors package does this when you pass an origin function.
const allowed = new Set(['https://app.example.com']);
app.use(cors({
origin: (origin, cb) => cb(null, !origin || allowed.has(origin)),
credentials: true,
}));Do not forget preflight
Browsers send an OPTIONS preflight before non-simple requests. The cors middleware handles it, but if you rate-limit or authenticate before CORS, you can accidentally block the preflight, which has no credentials to offer.
Example
import express from 'express';
import cors from 'cors';
const app = express();
// Explicit allow-list — exact matches only, sourced from config.
const allowedOrigins = new Set(
(process.env.CORS_ORIGINS ?? 'https://app.example.com').split(','),
);
const corsOptions = {
origin(origin, callback) {
// Same-origin / curl / server-to-server requests have no Origin header.
if (!origin) return callback(null, true);
if (allowedOrigins.has(origin)) return callback(null, true);
return callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // required for cookie-based auth
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600, // cache the preflight for 10 minutes
};
// Put CORS BEFORE auth/rate-limit so OPTIONS preflights are never blocked.
app.use(cors(corsOptions));
app.get('/api/me', (req, res) => res.json({ id: 1 }));When to use it
- An API that serves both a web app and a mobile app configures CORS to allow the web domain while the mobile app uses API keys instead of origin checks.
- A developer enables pre-flight caching with maxAge to reduce CORS preflight OPTIONS requests on high-traffic routes.
- A team uses a dynamic origin function to allow any *.example.com subdomain so preview deployments pass CORS without manual allowlist updates.
More examples
Wildcard subdomain CORS
Uses a regex to allow any HTTPS subdomain of example.com, enabling preview deployments without hardcoded origins.
const cors = require('cors');
app.use(cors({
origin: (origin, callback) => {
if (!origin || /^https:\/\/[^.]+\.example\.com$/.test(origin)) {
return callback(null, true);
}
callback(new Error('CORS: origin not allowed'));
},
credentials: true,
}));Route-specific CORS override
Applies different CORS policies per route: open to all origins for public reads, restricted for authenticated writes.
const cors = require('cors');
const publicCors = cors({ origin: '*' });
const privateCors = cors({ origin: 'https://app.example.com', credentials: true });
// Public read endpoint — any origin
app.get('/api/public', publicCors, ctrl.publicData);
// Private write endpoint — trusted origin only
app.post('/api/private', privateCors, ctrl.createItem);CORS with exposed headers
Exposes custom response headers to the browser so the frontend can read pagination counts and trace IDs from JavaScript.
app.use(cors({
origin: 'https://app.example.com',
exposedHeaders: ['X-Total-Count', 'X-Request-Id', 'X-Rate-Limit-Remaining'],
maxAge: 86400,
}));
Discussion