CORS in Express: Allowed Origins, Credentials, Preflight Requests and Dynamic Origins

Key takeaways

CORS (Cross-Origin Resource Sharing) is a security mechanism that controls which origins can access your API. Understanding CORS is essential for building secure web applications.

Introduction

CORS (Cross-Origin Resource Sharing) is a security feature implemented by browsers to control how web pages can request resources from different origins.

What is an Origin?

An origin consists of:

  • Protocol: http or https
  • Domain: example.com
  • Port: :3000
https://example.com:3000
└─┬──┘ └────┬─────┘└┬─┘
  │         │       └─ Port
  │         └───────── Domain
  └─────────────────── Protocol

Same origin:

https://example.com/api/users
https://example.com/api/posts

Different origins (CORS applies):

http://localhost:3000  → http://localhost:8000
https://example.com    → https://api.example.com
https://example.com    → https://example.com:3000

The port example on that last line is the one people trip over most, and it’s worth stating explicitly why: https://example.com and https://example.com:3000 are two genuinely different origins as far as the browser’s same-origin policy is concerned, even though they’re “the same domain” in every everyday sense — origin comparison is protocol and domain and port, all three matching exactly, with no partial credit for two out of three. This is precisely why a local dev setup with a frontend on :3000 and a backend on :8000 hits CORS immediately, even though both are localhost — same domain, same protocol, different port is still a cross-origin request as far as the browser is concerned.

The Problem

Without CORS, browsers block cross-origin requests:

// Frontend at http://localhost:3000
fetch('http://localhost:8000/api/users')
  .then(res => res.json())
  .catch(err => console.error(err));

// Error:
// Access to fetch at 'http://localhost:8000/api/users' 
// from origin 'http://localhost:3000' has been blocked by CORS policy

The single most important thing to understand about this error, and the thing that sends people down the wrong debugging path more than anything else: the request usually already reached the server and the server usually already responded — CORS is enforced by the browser, after the fact, by inspecting the response headers and deciding whether to actually hand the response to your JavaScript code or block it. I’ve watched developers spend real time convinced their server was somehow rejecting the request (adding logging, checking the route, restarting the server) when the server logs the request as a normal 200 the whole time — the fix genuinely can’t live on the client side at all (no amount of frontend code changes it), because the browser is the one refusing to release a response it already has, based on a header the server failed to include.

Basic Setup

npm install cors
const express = require('express');
const cors = require('cors');

const app = express();

// Enable CORS for all origins (not recommended for production)
app.use(cors());

app.get('/api/users', (req, res) => {
  res.json({ users: ['Alice', 'Bob'] });
});

app.listen(8000);

Plain cors() with no options sets Access-Control-Allow-Origin: *, meaning literally any website on the internet can call this API from a browser and read the response — fine for a genuinely public, read-only API with no sensitive data or authenticated actions behind it, but a meaningfully different security posture than most people reach for it expecting. This is the setting the FAQ above and the Common Errors section further down both warn about, and it’s worth internalizing why: the wildcard isn’t just “less secure” in the abstract, it specifically means any other origin’s JavaScript — including a malicious one a logged-in user happens to have open in another tab — can make authenticated requests to your API on that user’s behalf, unless something else (like the credentials restriction covered in Section 5) is also blocking it.

Specific Origins

// Allow single origin
app.use(cors({
  origin: 'http://localhost:3000'
}));

// Allow multiple origins
const allowedOrigins = [
  'http://localhost:3000',
  'https://example.com',
  'https://www.example.com'
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (like mobile apps, Postman)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
}));

The if (!origin) return callback(null, true) line is worth understanding rather than copy-pasting reflexively, because it’s easy to assume it’s a security hole and it isn’t quite what it looks like — a missing Origin header means the request didn’t come from a browser page at all (server-to-server calls, curl, Postman, a mobile app’s native HTTP client), and CORS is a browser enforcement mechanism specifically, so it has no meaning to apply to requests a browser never sent. That said, it’s not a free pass either: allowing no-origin requests through doesn’t grant them any special privilege beyond what your API’s actual authentication already requires — it just means CORS itself isn’t the gate for that category of caller, which is correct, since CORS was never designed to restrict non-browser clients in the first place.

Full Configuration

app.use(cors({
  origin: 'http://localhost:3000',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count'],
  credentials: true, // Allow cookies
  maxAge: 3600, // Cache preflight response for 1 hour
}));

maxAge is easy to skip as a minor detail, but it directly controls how often the browser has to re-run the preflight OPTIONS round trip covered in Section 6 — without it, some browsers default to a short cache duration (or none at all), meaning every single cross-origin PUT/DELETE/custom-header request pays for two full round trips (the preflight, then the actual request) instead of one. Setting it to a sensible value (an hour, a day) for a stable API meaningfully cuts request latency for real users, since the preflight result gets cached client-side and skipped entirely for the cache’s duration — worth tuning up from the default rather than leaving unset once an API is stable enough that its allowed methods/headers aren’t changing every deploy.

Credentials (Cookies)

// Server
app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true, // Allow cookies
}));

app.get('/api/profile', (req, res) => {
  // Can now read cookies
  res.json({ user: req.user });
});
// Client
fetch('http://localhost:8000/api/profile', {
  credentials: 'include', // Send cookies
})
  .then(res => res.json());

Both sides of this pair have to opt in independently, and forgetting either half is the single most common credentials-related bug — credentials: true on the server only tells the browser cookies are allowed for that origin, and credentials: 'include' on the client only tells the browser to actually send cookies with this particular request; missing either one means cookies silently don’t make it through, with no error thrown, just a request that behaves as if the user isn’t logged in. This is exactly the class of bug that’s confusing to debug because nothing actually fails loudly — res.user just comes back undefined on the server, and it’s easy to go looking for a session/auth bug when the real problem is one missing credentials flag on either end of the request.

Preflight Requests

For complex requests (PUT, DELETE, custom headers), browsers send a preflight OPTIONS request:

// Automatic preflight handling
app.use(cors());

// Manual preflight handling
app.options('/api/users', cors());

app.put('/api/users/:id', cors(), (req, res) => {
  // Update user
});

The preflight is the browser asking permission before sending a potentially state-changing request at all — for a PUT or DELETE, or any request carrying a custom header, the browser first fires an OPTIONS request asking “would you accept a real request like this,” and only sends the actual PUT/DELETE if the server’s OPTIONS response says yes. A plain GET or a simple POST with only standard headers is exempt from this and goes straight through (these are the historically-grandfathered “simple requests” from before CORS existed), which is worth knowing because it explains an otherwise-confusing asymmetry: an endpoint that works fine as a GET can suddenly hit CORS errors the moment it’s changed to a PUT or gains a custom header, even with identical CORS configuration, purely because it now triggers a preflight that didn’t exist before.

Dynamic Origins

app.use(cors({
  origin: (origin, callback) => {
    // Allow all subdomains
    if (!origin || origin.match(/\.example\.com$/)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  }
}));

// Or check database
app.use(cors({
  origin: async (origin, callback) => {
    const allowed = await db.allowedOrigins.findOne({ origin });
    callback(null, !!allowed);
  }
}));

The regex-based subdomain match (/\.example\.com$/) is convenient but worth being precise about what it actually permits — as written, it matches any subdomain of example.com, including ones you may not control or trust, like a subdomain provisioned by a third-party service on your DNS, or one an attacker could register if your DNS setup allows arbitrary subdomain creation. The database-backed variant below it is the more defensible pattern for anything beyond a small, fixed set of known subdomains — it makes the allowed-origins list an explicit, auditable, revocable data set rather than an implicit pattern match that grants access to anything matching the shape, known or not.

Route-Specific CORS

// Enable CORS for specific routes
app.get('/api/public', cors(), (req, res) => {
  res.json({ data: 'public' });
});

// No CORS for this route
app.get('/api/private', (req, res) => {
  res.json({ data: 'private' });
});

// Different CORS config per route
app.get('/api/admin', cors({
  origin: 'https://admin.example.com'
}), (req, res) => {
  res.json({ data: 'admin' });
});

Worth being clear-eyed about what route-specific CORS actually protects, and what it doesn’t: /api/private having no cors() middleware means a browser-based cross-origin request to it will be blocked by the browser, but the route itself remains just as directly reachable as ever to same-origin requests, server-to-server calls, curl, or anything else that isn’t a browser enforcing CORS — CORS restricts who can read the response from inside a browser page on another origin, it’s not an access-control or authentication mechanism, and treating the absence of a cors() call as “this route is private” instead of layering on real authentication is a common and genuinely risky misunderstanding.

Custom Headers

app.use(cors({
  origin: 'http://localhost:3000',
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Requested-With',
    'X-Custom-Header'
  ],
  exposedHeaders: [
    'X-Total-Count',
    'X-Page-Count'
  ]
}));

// Client can now read exposed headers
fetch('http://localhost:8000/api/users')
  .then(res => {
    const totalCount = res.headers.get('X-Total-Count');
    return res.json();
  });

exposedHeaders addresses a restriction that catches people off guard because it’s invisible until you need it: by default, a cross-origin response’s JavaScript-readable headers are limited to a small standard allowlist (Content-Type, Content-Length, and a handful of others) — a header like X-Total-Count genuinely arrives in the HTTP response and is visible in the browser’s network tab, but response.headers.get('X-Total-Count') returns null from JavaScript unless the server explicitly opts it into exposedHeaders. This is a distinct restriction from allowedHeaders (which governs headers the client is permitted to send) — mixing the two up is an easy mistake, since both sound like they’re about “which headers are allowed.”

Environment-Based Configuration

const corsOptions = {
  origin: process.env.NODE_ENV === 'production'
    ? 'https://example.com'
    : 'http://localhost:3000',
  credentials: true,
};

app.use(cors(corsOptions));

This ternary is the minimal version of a pattern worth generalizing rather than hardcoding two origins directly in the config: a real deployment usually has more than two environments (local dev, a preview/staging deploy per PR, production), and a single static string for each stage doesn’t scale to that — reading the allowed origin(s) from an environment variable (process.env.ALLOWED_ORIGIN, possibly comma-separated for multiple) set per deployment, rather than branching on NODE_ENV in code, keeps the CORS config itself unaware of how many environments exist and avoids a code change every time a new deployment target is added.

Common Errors and Solutions

Error 1: No ‘Access-Control-Allow-Origin’ header

// Problem: CORS not enabled
app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

// Solution: Add CORS
app.use(cors());

Error 2: Credentials not supported with ’*’

// Problem: Can't use credentials with wildcard origin
app.use(cors({
  origin: '*',
  credentials: true, // ❌ Error!
}));

// Solution: Specify exact origins
app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true, // ✅ Works
}));

This restriction isn’t the cors package being overly strict — it’s the browser itself refusing to combine a wildcard origin with credentials, and for good reason: Access-Control-Allow-Origin: * with credentials allowed would mean any website could make authenticated, cookie-carrying requests to your API on behalf of a logged-in user, which is close to the textbook definition of a CSRF vulnerability built directly into the CORS configuration. This is one of the few CORS misconfigurations the browser platform itself refuses to allow rather than leaving as a footgun — worth appreciating as a safety rail rather than an annoying restriction to work around.

Error 3: Method not allowed

// Problem: Method not in allowed methods
app.use(cors({
  methods: ['GET', 'POST'], // PUT not allowed
}));

app.put('/api/users/:id', (req, res) => {
  // CORS error!
});

// Solution: Add method
app.use(cors({
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));

Error 4: Header not allowed

// Problem: Custom header not allowed
fetch('http://localhost:8000/api/users', {
  headers: {
    'X-Custom-Header': 'value' // Not allowed!
  }
});

// Solution: Add to allowedHeaders
app.use(cors({
  allowedHeaders: ['Content-Type', 'X-Custom-Header'],
}));

These four errors share a common thread worth naming explicitly, since it’s the actual debugging heuristic that matters more than memorizing each individual fix: every one of them is the server’s CORS config being more restrictive than what the client is actually trying to do — a missing origin, a missing method, a missing header, or a credentials misconfiguration. The fix is essentially always “loosen the server’s CORS config to explicitly permit what the client legitimately needs,” never a client-side change, which circles back to the point made earlier: CORS errors are fundamentally a server-configuration problem being reported at the client, and debugging time is best spent reading the exact Access-Control-* headers the server actually returned (via browser dev tools’ Network tab) against what the failed request needed, rather than guessing at client-side fixes.

Production Configuration

const express = require('express');
const cors = require('cors');

const app = express();

// Production-ready CORS
const allowedOrigins = [
  'https://example.com',
  'https://www.example.com',
  'https://app.example.com',
];

app.use(cors({
  origin: (origin, callback) => {
    // Allow requests with no origin (mobile apps, curl, Postman)
    if (!origin) return callback(null, true);
    
    if (allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      console.warn(`CORS blocked: ${origin}`);
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count'],
  credentials: true,
  maxAge: 86400, // 24 hours
  optionsSuccessStatus: 200,
}));

app.listen(process.env.PORT || 8000);

The console.warn on a blocked origin is a small addition worth not skipping in a real deployment — without it, a legitimate origin that was simply forgotten from the allowlist (a new subdomain, a new client app) fails silently from the server’s perspective, and the only signal is a confused bug report from whoever’s trying to use it. Logging every blocked attempt gives you the actual data needed to tell “someone probing for vulnerabilities” apart from “a real client that needs to be added to the allowlist,” which otherwise look identical from a bare CORS rejection alone.

With Authentication

const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');

const app = express();

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true,
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// Protected route
app.get('/api/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    res.json({ user: decoded });
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

// Client
fetch('http://localhost:8000/api/profile', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
  credentials: 'include',
});

Worth noticing this example actually combines two independent, competing auth patterns rather than demonstrating a single idiomatic approach — the Authorization: Bearer header is the token-based pattern (the server reads the JWT from a header, no cookies involved), while credentials: 'include' is specifically for cookie-based auth. A pure Bearer-token API doesn’t need credentials: 'include' at all, since the token travels in a header rather than a cookie the browser manages; including it here does no harm but isn’t actually necessary for this particular auth flow to work — it’s only required when a request also needs to carry a cookie (a session ID, a refresh token stored as an httpOnly cookie) alongside or instead of the header.

CORS Proxy (Development)

For development, you can proxy requests:

// Next.js (next.config.js)
module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:8000/api/:path*',
      },
    ];
  },
};

// Vite (vite.config.js)
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
};

This is genuinely the option I reach for first over configuring CORS for local development at all — a dev-server proxy means the browser sees every request as same-origin (the frontend dev server transparently forwards /api/* calls to the real backend), so there’s no cross-origin request happening in the first place, and therefore nothing for CORS to block. It’s specifically a development-time convenience though, not a production pattern on its own — in production, either the frontend and API genuinely share an origin behind the same reverse proxy/load balancer (achieving the same effect for real), or the API needs real CORS configuration for whatever origins actually call it cross-origin in production, which this proxy setup deliberately sidesteps rather than solves.

Testing CORS

// Test CORS with curl
curl -H "Origin: http://localhost:3000" \
     -H "Access-Control-Request-Method: POST" \
     -H "Access-Control-Request-Headers: Content-Type" \
     -X OPTIONS \
     http://localhost:8000/api/users

// Check response headers
// Access-Control-Allow-Origin: http://localhost:3000
// Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE

Testing the actual preflight response directly with curl this way is worth doing before assuming a CORS fix worked, rather than only testing through the browser — it isolates exactly what the server returns for a given Origin/method/header combination without any of the browser’s own caching (recall maxAge from Section 4, which can make a stale preflight result linger and mask whether your server-side fix actually took effect) or JavaScript-level noise muddying the picture. If the Access-Control-Allow-Origin header in the response doesn’t match what you expect, the problem is unambiguously server-side configuration — no need to keep second-guessing the client code.

The CORS configuration that looks restrictive but is not

Never ship origin: '*' to production, enable credentials only for origins you trust, list allowed methods and headers explicitly, and test against a production-like setup, because a development proxy hides CORS problems entirely.

One configuration passes all of those checks on the surface and still opens the API: echoing the request’s Origin header back as Access-Control-Allow-Origin together with Access-Control-Allow-Credentials: true. It usually appears when someone needs credentials, discovers that browsers reject * with credentials, and sets origin: true or reflects the header to make the error go away. The effect is that any website can make authenticated requests with the user’s cookies and read the responses. Compare the origin against an explicit allowlist instead, including the scheme and port, and match whole origins rather than substrings, since a check like origin.endsWith('example.com') also accepts evil-example.com. When the allowed origin varies per request, send Vary: Origin so a shared cache does not serve one origin’s CORS headers to another.

Next Steps:

Resources: