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:
-
Decides whether the request’s
Originheader is allowed, according to theoriginoption. -
Writes the matching response headers:
Access-Control-Allow-Origin,Access-Control-Allow-CredentialsandAccess-Control-Expose-Headerson every response, plusAccess-Control-Allow-Methods,Access-Control-Allow-HeadersandAccess-Control-Max-Ageon preflight responses. -
When the request is a preflight (method
OPTIONS), answers it itself and ends the response, unlesspreflightContinuesays 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;
}
});
Source: Express middleware: cors
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 |
|---|---|---|
|
Boolean, string, RegExp, array of strings and RegExps, or a function |
|
|
Comma-delimited string or array of strings |
|
|
Comma-delimited string or array of strings |
Reflects the request’s |
|
Comma-delimited string or array of strings |
None; the header is not sent |
|
Boolean |
|
|
Integer number of seconds |
None; the header is not sent and the browser uses its own default |
|
Boolean |
|
|
Integer status code |
|
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:
truereflects the request’sOriginheader back, which allows every origin and, unlike'*', works together with credentials;falsedisables 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 ashttps://app.example.com.evil.testcan match. -
An array of strings and RegExps. The request’s
Originis 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 callscallback(error, allow), whereallowis any of the values above (usuallytrueorfalse). Theoriginargument isundefinedfor requests that carry noOriginheader, 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;
}
});
Source: Express middleware: cors
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-Originmust be a specific origin, never'*'. Withcredentials: true,origin: '*'(the default) fails in the browser. Use an allowlist, ororigin: trueonly 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-MethodsandAccess-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' }), orwithCredentials = trueon anXMLHttpRequest. 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;
}
});
Source: Express middleware: cors
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;
}
});
Source: Express middleware: cors
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:
-
The server allows the front end’s origin explicitly and sets
credentials: true. -
The page makes requests with
credentials: 'include'. -
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
localhostare different origins but the same site, so cookies work with the defaultSameSite=Lax. Two different registrable domains (app.example.comandapi.other.test) are cross-site: the cookie must then beSameSite=NoneandSecure, 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/apifrom 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;
}
});
Source: Express middleware: session
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: Originhandling 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 |
Allows every origin and breaks as soon as cookies or credentials are added. Not a production configuration. |
Use an origin allowlist from configuration, with |
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 |
|
Express 5 rejects a bare |
Use |
Allowing |
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 |
The body-parser package is replaced by |
Install the current |
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.
cors2.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. |
|
List the allowed origins explicitly. |
The browser says the preflight did not pass, but the request works in |
The preflight response lacks a header or method the page sends. |
Add the header to |
|
The header is not in |
Expose every non-safelisted header the page reads. |
A |
The response was produced by a layer registered before |
Register |
|
Express 5 no longer accepts a bare |
Use |
The cookie is not stored or sent. |
The page omits |
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 |
Keep the |
The preflight is repeated before every call. |
No |
Set |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: What is CORS?, Middleware Fundamentals, Cookies & Sessions, Security Best Practices, GraphQL with Express, Real Time: WebSockets & SSE