cheat sheet
cors
Package-level reference for cors on npm — preflight handling, credentialed requests, dynamic origins, and Express integration.
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