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

Example · javascript
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.

Example · js
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.

Example · js
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.

Example · js
app.use(cors({
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count', 'X-Request-Id', 'X-Rate-Limit-Remaining'],
  maxAge: 86400,
}));

Discussion

  • Be the first to comment on this lesson.