CORS in Express

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The cors package used here is version 2.8.6, checked together with the official documentation on 2026-10-29. CORS is the browser mechanism that lets a page served from one origin read responses from another, and it is the first thing a separate front end (a Vite or React development server, a Vue single-page application) runs into when it calls an Express API. The theory, the headers and the browser rules are explained in What is CORS?; this page shows only how to apply them with the cors middleware in the Bookshelf API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets.

What the middleware does

The cors package is one of the official middleware modules maintained under the expressjs organization (see Official Middleware Modules). It is a small function that, for every request, does three things:

  1. Decides whether the request’s Origin header is allowed, according to the origin option.

  2. Writes the matching response headers: Access-Control-Allow-Origin, Access-Control-Allow-Credentials and Access-Control-Expose-Headers on every response, plus Access-Control-Allow-Methods, Access-Control-Allow-Headers and Access-Control-Max-Age on preflight responses.

  3. When the request is a preflight (method OPTIONS), answers it itself and ends the response, unless preflightContinue says otherwise.

CORS is enforced by the browser, not by the server. The middleware never rejects a request: a disallowed origin simply gets a response without the permission headers, and the browser then refuses to expose it to the page’s JavaScript. The request still reached the server and the handler still ran, so CORS is not an authorization mechanism, and it does not stop curl, scripts or other servers.

npm install cors
npm install --save-dev @types/cors

The second package is needed only for TypeScript projects, see TypeScript with Express.

The default behaviour

Calling cors() with no arguments applies this configuration:

// What cors() uses when you pass nothing
const defaults = {
  origin: '*',
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
  preflightContinue: false,
  optionsSuccessStatus: 204,
};

In words: every origin is allowed, no credentials are allowed, the allowed methods are the six listed, and preflight requests are answered by the middleware with status 204. allowedHeaders is not in the defaults on purpose: when it is unset, the middleware reflects whatever the browser asked for in Access-Control-Request-Headers.

// open-cors.mjs
import cors from 'cors';
import express from 'express';

const app = express();

// Fine for a public, read-only API with no cookies and no user-specific data.
app.use(cors());

app.get('/api/books', (req, res) => {
  res.json([{ id: 1, title: 'Example book' }]);
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

This is the shortest example in many tutorials, and it is the one that causes trouble later; see Why cors() with no options is wrong for authenticated APIs.

Every option

The options object is the single argument of cors(). The table summarizes it; each option is detailed in the sections that follow.

Option Accepted values Default

origin

Boolean, string, RegExp, array of strings and RegExps, or a function (origin, callback)

'*'

methods

Comma-delimited string or array of strings

'GET,HEAD,PUT,PATCH,POST,DELETE'

allowedHeaders

Comma-delimited string or array of strings

Reflects the request’s Access-Control-Request-Headers

exposedHeaders

Comma-delimited string or array of strings

None; the header is not sent

credentials

Boolean

false; the header is not sent

maxAge

Integer number of seconds

None; the header is not sent and the browser uses its own default

preflightContinue

Boolean

false

optionsSuccessStatus

Integer status code

204

origin

origin controls Access-Control-Allow-Origin. It accepts five kinds of value:

  • A string sets that exact value for every request: origin: 'https://app.example.com'. The string '*' allows every origin. A string must be a full origin (scheme, host and optional port, with no path and no trailing slash).

  • A Boolean: true reflects the request’s Origin header back, which allows every origin and, unlike '*', works together with credentials; false disables CORS for the request (no CORS headers are written).

  • A RegExp is tested against the request’s Origin; on a match the origin is reflected. Anchor it, or a hostile origin such as https://app.example.com.evil.test can match.

  • An array of strings and RegExps. The request’s Origin is compared with each entry and reflected on a match. This is the normal way to write an allowlist.

  • A function (origin, callback) for decisions that depend on data, such as a tenant table. It calls callback(error, allow), where allow is any of the values above (usually true or false). The origin argument is undefined for requests that carry no Origin header, which means same-origin browser requests and non-browser clients.

// origin-allowlist.mjs
import cors from 'cors';
import express from 'express';

const allowedOrigins = new Set(
  (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
    .split(',')
    .map((value) => value.trim())
    .filter(Boolean),
);

const app = express();

app.use(
  cors({
    origin(origin, callback) {
      // Requests without an Origin header are same-origin or not from a browser.
      if (origin === undefined || allowedOrigins.has(origin)) {
        callback(null, true);
        return;
      }
      callback(null, false); // no CORS headers: the browser blocks the read
    },
  }),
);

app.get('/api/books', (req, res) => {
  res.json([{ id: 1, title: 'Example book' }]);
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Two points about the function form. Passing an error as the first argument of callback forwards it to the Express error handlers, so a failing lookup becomes a 500 rather than a silent block (see Error Handling); a deliberate rejection should use callback(null, false), because passing new Error('Not allowed by CORS') turns every foreign page’s preflight into a server error that shows up in your logs and monitoring. And an allowlist that lives in an environment variable keeps the same code running in development, staging and production; see Project Structure & Configuration.

Whenever the value is not the fixed '*', the middleware also adds Vary: Origin to the response, so caches keep one copy per origin. If you add your own caching headers, keep that header (see Content Negotiation & HTTP Caching).

methods

methods sets Access-Control-Allow-Methods on preflight responses. The default allows the six methods listed above. Narrow it when an API does not use them all, for example methods: ['GET', 'POST'] for a read-and-create endpoint. Methods that are CORS-safelisted (GET, HEAD and POST with a simple content type) never trigger a preflight, so the option matters mainly for PUT, PATCH and DELETE. Note that the default does not include the newer QUERY method that Express 5.3 recognizes; add it explicitly if you use it.

allowedHeaders

allowedHeaders sets Access-Control-Allow-Headers on preflight responses: the request headers the page may send. When it is not set, the middleware reflects the headers named in the request’s Access-Control-Request-Headers, which is convenient and allows anything the page asks for. Setting an explicit list is stricter and documents the contract:

const corsOptions = {
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
};

Any custom header sent by the front end (a bearer token in Authorization, an X-Request-Id, a Content-Type of application/json) makes the request non-simple and therefore triggers a preflight, and the preflight must list that header. The error message in the browser console names the missing header.

exposedHeaders

exposedHeaders sets Access-Control-Expose-Headers on actual responses. By default JavaScript on the page can read only the CORS-safelisted response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma). Any other header the front end needs to read, for instance the pagination headers of the Bookshelf API, or Location after a 201, must be exposed:

const corsOptions = {
  exposedHeaders: ['Location', 'X-Total-Count', 'RateLimit-Remaining'],
};

The header is visible in the browser’s network tab even when it is not exposed; the failure is that response.headers.get('X-Total-Count') returns null in script.

credentials

credentials: true sends Access-Control-Allow-Credentials: true, which lets the browser attach cookies, HTTP authentication and TLS client certificates to cross-origin requests and expose the response to the page. The rules are strict, because the browser rejects the response otherwise:

  • Access-Control-Allow-Origin must be a specific origin, never '*'. With credentials: true, origin: '*' (the default) fails in the browser. Use an allowlist, or origin: true only when you really mean "any origin may make authenticated requests", which you almost never do.

  • The same applies to Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers: browsers do not accept the wildcard value for them on credentialed requests.

  • The page must opt in too: fetch(url, { credentials: 'include' }), or withCredentials = true on an XMLHttpRequest. Without it the browser neither sends nor accepts cookies.

Credentials are only useful when the API authenticates with cookies. APIs that use a bearer token in the Authorization header do not need credentials: true, because the header is set by script and is not an ambient credential. See Credentials with cookies for the cookie case.

maxAge

maxAge sets Access-Control-Max-Age on preflight responses: how many seconds the browser may cache the preflight result, so that it does not repeat an OPTIONS request before every call. Without it the browser uses its own default, which is short (five seconds in Chromium-based browsers when the header is absent). Browsers also cap the value, at 7200 seconds (two hours) in Chromium and 86400 seconds (one day) in Firefox, so a larger number is silently reduced.

const corsOptions = {
  maxAge: 600, // ten minutes
};

preflightContinue and optionsSuccessStatus

By default the middleware ends every preflight itself, with status optionsSuccessStatus (204) and an empty body. optionsSuccessStatus exists because some legacy browsers and clients choke on a 204; set it to 200 for them.

preflightContinue: true instead calls next() after writing the CORS headers on an OPTIONS request, so a later handler can answer. Use it only when your own code must produce the preflight response, for example to add headers that cors does not set. Remember that a preflight carries no cookies and no body, so the later handler must not require authentication.

Global, per-route and per-router CORS

app.use(cors(options)) placed before the routes applies to every route, and it is the right default for an API whose every endpoint serves the same front end. The middleware is an ordinary (req, res, next) function, so it can also be attached to a single route or router, which is how you give a public endpoint open CORS while the rest stays locked down:

// per-route-cors.mjs
import cors from 'cors';
import express from 'express';

const app = express();
const apiRouter = express.Router();

// The private API: only the Bookshelf front end, with cookies.
apiRouter.use(
  cors({
    origin: ['https://app.example.com'],
    credentials: true,
    exposedHeaders: ['Location'],
    maxAge: 600,
  }),
);
apiRouter.get('/me', (req, res) => {
  res.json({ id: 1, name: 'Example reader' });
});
apiRouter.post('/reviews', express.json(), (req, res) => {
  res.status(201).location('/api/reviews/1').json({ id: 1 });
});

// A public endpoint: any origin may read it, no credentials.
app.get('/public/catalog', cors(), (req, res) => {
  res.json([{ id: 1, title: 'Example book' }]);
});

app.use('/api', apiRouter);

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Routers and app.use mounting are explained in Routers & Modular Apps. A middleware attached to one route answers the preflight only if an OPTIONS route exists for the same path, which is the subject of the next section.

Preflight requests and Express 5 paths

A browser sends a preflight, an OPTIONS request carrying Origin, Access-Control-Request-Method and Access-Control-Request-Headers, before any cross-origin request that is not simple: a method other than GET, HEAD or POST, a custom header, or a Content-Type such as application/json. Only if the preflight answer allows the real request does the browser send it.

When app.use(cors()) is registered before the routes, preflights for every path are answered by the middleware before routing, with no extra code, even for paths that have no OPTIONS route. That is why the global form is preferred. When CORS is attached per route, the browser’s OPTIONS request has no handler and receives a 404, so you must register the preflight explicitly with the same options:

// per-route-preflight.mjs
import cors from 'cors';
import express from 'express';

const app = express();

const privateCors = cors({
  origin: ['https://app.example.com'],
  credentials: true,
});

// Express 5: a catch-all path needs a named wildcard (see below).
app.options('/api/reviews/:id', privateCors);
app.delete('/api/reviews/:id', privateCors, (req, res) => {
  res.status(204).end();
});

// All preflights under /admin, using the Express 5 catch-all syntax.
app.options('/admin/{*splat}', privateCors);

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The Express 5 detail matters here. Express 4 tutorials (and the middleware’s own older examples) write app.options('*', cors()) to answer every preflight. Express 5 uses path-to-regexp 8 through its router, where a bare * is no longer a valid path and throws at startup with a "Missing parameter name" error. The replacement is a named wildcard, /{*splat} to match everything including the root (the braces make the segment optional), or /*splat to match one or more segments. The full syntax is in Route Paths & Parameters. In practice the best fix is to avoid the catch-all altogether and register app.use(cors(options)) once, before the routes.

Order matters for the same reason as with every middleware (see Middleware Fundamentals): cors() must run before the layer that produces the response, including rate limiters, authentication and the error handlers, otherwise a 401, a 429 or a 500 reaches the browser without CORS headers and the page reports a misleading "CORS error" instead of the real status.

Credentials with cookies

A common setup is a front end on one origin calling an API on another and authenticating with a session cookie. Three conditions must hold together:

  1. The server allows the front end’s origin explicitly and sets credentials: true.

  2. The page makes requests with credentials: 'include'.

  3. The cookie is delivered by the browser, which depends on the cookie attributes and on whether the two origins are same-site. Two ports on localhost are different origins but the same site, so cookies work with the default SameSite=Lax. Two different registrable domains (app.example.com and api.other.test) are cross-site: the cookie must then be SameSite=None and Secure, and browsers increasingly block third-party cookies altogether, so a cross-site cookie design is fragile. Prefer serving the API under a subdomain of the same registrable domain, or proxying /api from the front end’s host so that the browser sees one origin and CORS is not needed at all.

// credentialed-cors.mjs
import cors from 'cors';
import express from 'express';
import session from 'express-session';

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error('SESSION_SECRET is not set');

const app = express();

app.set('trust proxy', 1); // behind one reverse proxy, needed for secure cookies

app.use(
  cors({
    origin: ['https://app.example.com'],
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'X-CSRF-Token'],
    maxAge: 600,
  }),
);

app.use(
  session({
    name: 'bookshelf.sid',
    secret: sessionSecret,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      // app.example.com and api.example.com are cross-origin but same-site, so Lax is delivered and keeps
      // the cookie off cross-site requests. Only a front end on another registrable domain needs 'none'.
      sameSite: 'lax',
      maxAge: 1000 * 60 * 60,
    },
  }),
);

app.get('/api/me', (req, res) => {
  res.json({ authenticated: Boolean(req.session.userId) });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The matching browser call is fetch('https://api.example.com/api/me', { credentials: 'include' }). A page that authenticates with cookies across origins also needs protection against cross-site request forgery, because CORS does not stop a form post from another site; see Security Best Practices and Cookies & Sessions. The environment-dependent secure value above keeps local development over plain HTTP working while production uses HTTPS. sameSite stays lax in both, because the front end and the API share the registrable domain example.com; switching to none there would gain nothing and would let the browser attach the cookie to requests from any site, removing the CSRF protection that Lax provides.

Why cors() with no options is wrong for authenticated APIs

app.use(cors()) answers "yes" to every origin. For a public, read-only API that is acceptable. For an API with authenticated endpoints it is the wrong default, for these reasons:

  • Any website a logged-in user visits can read the API’s responses if the user’s browser attaches credentials automatically (cookies, or HTTP authentication on an intranet). The same-origin policy is exactly what protected those responses, and the wildcard removes it.

  • Developers who hit the "wildcard with credentials is rejected" error often "fix" it with origin: true, which reflects any origin and turns the same mistake into a working one.

  • Bearer-token APIs are less exposed, because the token is not sent automatically, but an open policy still lets any page that obtains a token (through XSS or a phishing page) use it from a browser, and it makes internal APIs readable from any site a developer or employee visits.

  • A reflected origin without Vary: Origin handling by your own caching layer can poison caches; the middleware adds the header for you, but custom proxies must preserve it.

The safe pattern is an explicit allowlist, configured per environment, with credentials only where cookies are used, an explicit methods and allowedHeaders, and a maxAge. Treat the allowlist like any other configuration that can leak data: review additions, and never include null (sandboxed documents and local files send Origin: null) or wildcard subdomain patterns you do not control. When a public endpoint exists, give it its own cors() as in the per-route example, rather than opening the whole API.

Where the books are outdated

The consulted books that pair an Express back end with a separate front end touch CORS in a few lines, usually to get the development server talking to the API. Compared with the current documentation, these points have changed or were unsafe:

Typical advice Status What to do today

Calling cors() with no options, or with the origin left open, so the front end "just works".

Allows every origin and breaks as soon as cookies or credentials are added. Not a production configuration.

Use an origin allowlist from configuration, with credentials only where needed (Why cors() with no options is wrong for authenticated APIs).

Registering the middleware after the routes, or only for some routes.

Preflights and error responses then lack CORS headers, and the browser reports a CORS failure.

Register cors() before the routes and the error handlers (Preflight requests and Express 5 paths).

app.options('*', cors()) to answer every preflight.

Express 5 rejects a bare * path at startup.

Use app.use(cors(options)) once, or /{*splat} where a path is required.

Allowing '*' for development and forgetting to remove it, or using it with Socket.IO.

The same configuration reaches production. WebSocket handshakes have their own origin options.

Read the allowlist from an environment variable, with a development default.

Tutorials from the Express 4 era that rely on body-parser or on an unpinned cors version.

The body-parser package is replaced by express.json() and express.urlencoded(); cors itself is still maintained (2.8.6).

Install the current cors and use the built-in parsers (see Body Parsing).

The changes in the framework itself are collected in Migrating & What’s Changed.

What changed recently

  • Express 5 (5.0.0 in September 2024, 5.3.0 in October 2026) only changes how the middleware is mounted, not the package: wildcard paths use the path-to-regexp 8 syntax, and a rejected promise in a handler reaches the error handlers by itself. cors 2.8.x works unchanged with both Express 4 and Express 5.

  • Browsers keep tightening the rules around cross-site cookies and private network access, so a credentialed cross-site setup that worked in an older browser can stop working without any server change. Test with the browsers you support, and prefer a same-site or same-origin deployment.

  • There is no built-in CORS in Express; the middleware remains the recommended approach, and frameworks built on Express (see Frameworks Built on Express & Alternatives) expose the same options under their own configuration.

Common mistakes

Symptom Cause Fix

The browser reports that the wildcard cannot be used with credentials.

credentials: true together with the default '*' origin.

List the allowed origins explicitly.

The browser says the preflight did not pass, but the request works in curl.

The preflight response lacks a header or method the page sends.

Add the header to allowedHeaders, or the method to methods.

response.headers.get('X-Total-Count') is null in script.

The header is not in exposedHeaders.

Expose every non-safelisted header the page reads.

A 401, 404 or 500 shows as a CORS error.

The response was produced by a layer registered before cors(), or by an error handler that skips it.

Register cors() first so that every response carries the headers.

app.options('*', cors()) throws at startup.

Express 5 no longer accepts a bare * path.

Use /{*splat}, or one global app.use(cors()).

The cookie is not stored or sent.

The page omits credentials: 'include', or the cookie is SameSite=Lax on a cross-site call.

Opt in on the page, and review the cookie attributes (Credentials with cookies).

Different users see each other’s CORS decision.

A cache keyed without the Origin header.

Keep the Vary: Origin header that the middleware writes.

The preflight is repeated before every call.

No maxAge is set.

Set maxAge to a few minutes.