cors#
What it is#
cors is the canonical Cross-Origin Resource Sharing middleware for Express / Connect-style Node servers. It handles the CORS preflight (OPTIONS) requests, sets Access-Control-Allow-* headers on responses, and supports static origin allowlists, regex patterns, or a function for dynamic per-request decisions. The package is small, dependency-light, and effectively the standard for any Express API that browsers call.
Reach for cors when you operate a browser-facing API. Skip it when your API only receives server-to-server requests (server clients don’t enforce same-origin) or when you’re on a framework with built-in CORS (@fastify/cors, Hono’s cors() middleware).
Install#
npm install cors
Output: added cors to dependencies
pnpm add cors
Output: added 1 package, linked from store
yarn add cors
Output: added cors
bun add cors
Output: installed cors
For TypeScript:
npm install --save-dev @types/cors
Output: added @types/cors to devDependencies
Versioning & Node support#
Current line is cors@2.x — extremely stable. The package has stayed on 2.x since 2015.
cors@2— Node 4+ effective; works on every modern Node. Bug fixes only; semantics frozen.cors@3is on npm but is a no-op meta-package; ignore unless explicitly required by upstream tooling.
CORS itself is a browser-side standard — the middleware just sets headers. There is little to change on the server side. Pin minor in production ("cors": "2.x").
Package metadata#
- Maintainer: Express TC (
expressjs/cors) - Project home: github.com/expressjs/cors
- Docs: github.com/expressjs/cors#readme
- npm: npmjs.com/package/cors
- License: MIT
- First released: 2013
- Downloads: ~12 million+ weekly downloads.
Peer dependencies & extras#
No peer-deps. Companion packages:
express— host framework (cors mounts as middleware)connect— also supports corscookie-parser— combine with credentialed CORShelmet— secure headers; orthogonal to CORSbody-parser/express.json— mount cors BEFORE body parsers so preflights short-circuitexpress-rate-limit— pairs naturally with cors on public APIs
For Fastify, use @fastify/cors. For Hono, use the built-in cors() middleware. For Koa, @koa/cors.
Alternatives#
| Approach | Trade-off |
|---|---|
@fastify/cors | Fastify-native. Same options, encapsulated per-context. |
Hono cors() | Hono / edge runtimes. |
@koa/cors | Koa. |
| Manual header writes | Tiny apps; one or two routes. Don’t roll your own for production. |
| Reverse-proxy CORS (Nginx, Cloudflare) | Strip / set headers at the edge. Works but harder to debug at the app level. |
Real-world recipes#
Simple enable#
The zero-config form allows any origin (Access-Control-Allow-Origin: *). Suitable for fully public, unauthenticated APIs.
import express from "express";
import cors from "cors";
const app = express();
app.use(cors());
app.get("/api/public", (req, res) => res.json({ ok: true }));
app.listen(3000);
Output: any browser origin can call the API. Preflight OPTIONS requests return 204 with the right headers. Credentials (Cookie, Authorization) are NOT permitted with * — browsers refuse to attach credentials to wildcard origins.
Static origin allowlist#
The right default for most production APIs — explicit list of trusted origins.
import express from "express";
import cors from "cors";
const app = express();
app.use(cors({
origin: ["https://app.example.com", "https://staging.example.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 86400, // browser caches preflight for 1 day
}));
app.get("/api/data", (req, res) => res.json({ ok: true }));
app.listen(3000);
Output: browsers on listed origins succeed; others get a CORS error in the browser console. maxAge reduces preflight chatter.
Per-route CORS#
Mount cors per route to apply different policies — public reads, restricted writes.
import express from "express";
import cors from "cors";
const app = express();
const publicCors = cors({ origin: "*" });
const privateCors = cors({
origin: "https://app.example.com",
credentials: true,
});
app.get("/api/public/:id", publicCors, (req, res) => res.json({ id: req.params.id }));
app.post("/api/private", privateCors, (req, res) => res.json({ saved: true }));
app.listen(3000);
Output: public reads from any origin; writes only from the trusted app origin and with credentials.
Credentials + dynamic origin#
Browsers permit credentials: true only when the response echoes the exact request origin (no wildcards). Use a function to validate the origin per request.
import express from "express";
import cors from "cors";
const allowed = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
const app = express();
app.use(cors({
origin(origin, cb) {
if (!origin) return cb(null, false); // same-origin / non-browser
if (allowed.has(origin)) return cb(null, true);
return cb(new Error("origin not allowed"));
},
credentials: true,
exposedHeaders: ["X-Total-Count", "X-Request-Id"],
}));
app.get("/api/me", (req, res) => res.json({ user: "alice" }));
app.listen(3000);
Output: allowed origins see Access-Control-Allow-Origin: <their-origin> and Access-Control-Allow-Credentials: true. Other origins get a 500 from the throw. Custom response headers are exposed for JavaScript via exposedHeaders.
For a less-disruptive rejection, pass cb(null, false) instead of cb(error). The middleware then omits CORS headers; browsers reject; your server doesn’t 500.
Preflight handling#
By default, cors() handles OPTIONS for any route it’s mounted on. For routes where you want explicit preflight (rare), use app.options("/api/x", cors(opts)).
import express from "express";
import cors from "cors";
const app = express();
const corsOptions = {
origin: "https://app.example.com",
methods: ["DELETE", "PATCH"],
};
app.options("/api/items/:id", cors(corsOptions));
app.delete("/api/items/:id", cors(corsOptions), (req, res) => res.sendStatus(204));
app.listen(3000);
Output: OPTIONS /api/items/:id returns 204 with the right preflight headers; DELETE then succeeds.
Mount cors BEFORE body-parser, helmet, and routes so preflights short-circuit without parsing bodies:
app.use(cors(opts));
app.use(helmet());
app.use(express.json());
app.use("/api", apiRouter);
Production deployment#
Origin allowlist hygiene#
Maintain the allowlist in environment-driven config, not source. Different environments (staging, prod, preview deploys) have different origins.
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
app.use(cors({
origin(origin, cb) {
if (!origin || ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
return cb(null, false);
},
}));
Output: CORS_ORIGINS=https://app.example.com,https://staging.example.com in env; restart re-reads. No code change per environment.
Behind a reverse proxy / CDN#
Many CDNs (Cloudflare, AWS CloudFront) can set CORS headers themselves. Pick ONE source of truth — either the app or the edge — to avoid double-set headers (which violate the spec; only one Access-Control-Allow-Origin is permitted).
If the CDN handles CORS, disable the middleware. If the app handles it, ensure the CDN passes Origin headers through unmodified.
Vary header#
cors automatically adds Vary: Origin (plus Access-Control-Request-Method and Access-Control-Request-Headers for preflights). This is mandatory — without it, CDNs cache the response with the wrong origin’s CORS headers and break other callers. Verify with curl -v -H "Origin: https://app.example.com" https://api.example.com/.
Preflight cache#
maxAge tells browsers how long to cache the preflight response. Common values:
0— never cache (chatty, simplest to debug)86400— one day (production default; rotate quickly if you change CORS config)7200— two hours (compromise)
Browsers cap maxAge (Firefox 24h, Chrome 7200s) regardless of what you set.
Health check exemption#
Health check endpoints called by load balancers don’t need CORS — they aren’t browser requests. Mount cors on /api, not globally, to skip preflight noise on /healthz.
app.get("/healthz", (req, res) => res.json({ ok: true }));
app.use("/api", cors(opts), apiRouter);
Performance tuning#
CORS overhead is small but not zero — middleware runs on every request.
- Mount per-router, not globally.
/api-only mounting avoids running cors on static-asset routes. - Larger
maxAgereduces preflight frequency. 24h is fine if your CORS policy doesn’t change daily. - Static
origin: "https://app.example.com"is faster than a function. Functions run per request. - Static array
origin: ["a", "b"]is still fast — internally aSet-like lookup. - Avoid per-request DB lookups in
origin(origin, cb). Cache decisions in memory with a TTL. - Preflight responses are tiny — no body, just headers. There’s little to optimize beyond
maxAge.
Version migration guide#
cors@2 is the long-stable line. There has been no breaking change for years.
Most “migration” work is configuration — not version bumps:
From wildcard to allowlist#
// before — works for unauthenticated public APIs only
app.use(cors());
// after — production-safe
app.use(cors({
origin: ["https://app.example.com"],
credentials: true,
}));
Migrating from cors to @fastify/cors#
If you’re switching frameworks, the options are almost identical. The biggest gotcha is encapsulation: @fastify/cors mounted in a sub-plugin doesn’t affect parent routes.
From manual headers to cors#
If you have a custom CORS implementation, watch for these landmines that cors handles correctly:
Vary: Originheader (critical for caching)Access-Control-Allow-Credentials: truerequires echoing the exact origin (no*)- Preflight responses must end with 204, not 200, when the body is empty
Access-Control-Allow-Headersis request-specific; echoAccess-Control-Request-Headersrather than hard-coding
Security considerations#
- NEVER set
origin: true(reflect request origin) withcredentials: truein production. This is effectively “trust everyone with credentials” — an attacker site can read authenticated responses. - NEVER set
origin: "*"withcredentials: true. Browsers refuse the combination, but if a middleware bug allows it, you’ve exposed authenticated data. - Validate origins exactly. Don’t
origin.includes("example.com")— that matchesevil-example.com.attacker.com. UseURLparsing or exact match. - CORS is NOT a security boundary by itself. It’s a browser policy. Server-to-server requests ignore CORS. Always pair with auth (cookies, JWTs, API keys, mTLS).
exposedHeadersleaks header names to JavaScript. Don’t expose internal headers likeX-Internal-User-Id.- Beware
nullorigin. File-protocol pages, sandboxed iframes, and certain redirects sendOrigin: null. Some configurations matchnullto the allowlist — explicitly exclude. - Use
helmet’scrossOriginResourcePolicyfor additional protection on responses (same-origin/same-site/cross-origin). - Preflight cache invalidation. Changing CORS policy doesn’t immediately propagate — browsers cached
maxAgepreflights. ConsidermaxAge: 0during a policy rollout window. - Don’t echo arbitrary
Access-Control-Request-Headers. The middleware does this;allowedHeaders: ["Content-Type", "Authorization"]is the explicit alternative. - Restrict
methods.methods: ["GET"]rather than the default broad list.
Testing & CI integration#
For unit tests, supertest exercises CORS headers without a real browser.
import { test, expect } from "vitest";
import express from "express";
import cors from "cors";
import request from "supertest";
const app = express();
app.use(cors({ origin: "https://app.example.com", credentials: true }));
app.get("/api/me", (req, res) => res.json({ user: "alice" }));
test("allowed origin gets ACAO header", async () => {
const res = await request(app)
.get("/api/me")
.set("Origin", "https://app.example.com");
expect(res.status).toBe(200);
expect(res.headers["access-control-allow-origin"]).toBe("https://app.example.com");
expect(res.headers["access-control-allow-credentials"]).toBe("true");
});
test("preflight succeeds for known origin", async () => {
const res = await request(app)
.options("/api/me")
.set("Origin", "https://app.example.com")
.set("Access-Control-Request-Method", "GET");
expect(res.status).toBe(204);
});
Output: 2 passed. Each test runs in milliseconds; no browser involved.
For full browser-side testing, Playwright or Cypress hits the API from a real browser and asserts that fetches succeed or fail as expected.
Ecosystem integrations#
| Tool | Role |
|---|---|
express | Host framework. |
@fastify/cors | Fastify equivalent (different package). |
Hono cors() | Edge runtimes. |
@koa/cors | Koa equivalent. |
helmet | Combine for full HTTP-security middleware stack. |
next-cors | Next.js wrapper for API routes. |
cors-anywhere | Public CORS proxy (use only for dev; never expose). |
express-rate-limit | Pair with cors on public APIs. |
Troubleshooting common errors#
Access to fetch at '...' from origin '...' has been blocked by CORS policy (browser console) — origin not in allowlist, or preflight failed. Check server response headers with curl -v -H "Origin: <origin>".
Cookies not sent on cross-origin requests — client must call fetch(url, { credentials: "include" }) AND server must return Access-Control-Allow-Credentials: true AND origin must echo (not *). All three required.
Preflight returns 404 — Express version mismatch, or route doesn’t accept OPTIONS. Mount cors() BEFORE the route handler so cors handles OPTIONS first.
OPTIONS reaches the handler — cors not mounted at all, or mounted after body-parser/auth middleware that 401s. Move cors to the top of the stack.
Access-Control-Allow-Origin: * with credentials request — browsers reject. Either drop credentials or echo the exact origin.
Two Access-Control-Allow-Origin headers — CDN and app both set it. Pick one.
Vary: Origin missing — using a custom CORS implementation. cors-the-package always sets it.
Slow API after enabling cors — origin(origin, cb) function does a DB lookup per request. Cache the decision.
Preflight cached too long — old maxAge value, policy change not visible. Lower maxAge during rollouts.
Custom header rejected — header not in allowedHeaders. Add it or rely on the default reflection.
When NOT to use this#
- Server-to-server APIs. Server clients (cURL, Python
requests, Gonet/http) don’t enforce CORS. The middleware just adds latency. - Webhook endpoints. Webhooks are server-to-server — CORS adds nothing.
- Same-origin SPAs (proxy through your own backend). If the frontend and API share an origin (
/api/*on the same host), no CORS is needed. - You’re on Fastify, Hono, or Koa. Use their native middleware.
- CDN handles CORS. Don’t double-configure.
- Static-asset-only servers. Asset CORS (for fonts, etc.) is usually handled by the CDN/storage layer.
See also#
- npm: express — host framework
- Concept: http — preflight, headers, methods
- Concept: api — cross-origin API design