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

bash
npm install cors

Output: added cors to dependencies

bash
pnpm add cors

Output: added 1 package, linked from store

bash
yarn add cors

Output: added cors

bash
bun add cors

Output: installed cors

For TypeScript:

bash
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@3 is 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 cors
  • cookie-parser — combine with credentialed CORS
  • helmet — secure headers; orthogonal to CORS
  • body-parser / express.json — mount cors BEFORE body parsers so preflights short-circuit
  • express-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

ApproachTrade-off
@fastify/corsFastify-native. Same options, encapsulated per-context.
Hono cors()Hono / edge runtimes.
@koa/corsKoa.
Manual header writesTiny 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.

javascript
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.

javascript
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.

javascript
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.

javascript
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)).

javascript
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:

javascript
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.

javascript
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.

javascript
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 maxAge reduces 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 a Set-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

javascript
// 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: Origin header (critical for caching)
  • Access-Control-Allow-Credentials: true requires echoing the exact origin (no *)
  • Preflight responses must end with 204, not 200, when the body is empty
  • Access-Control-Allow-Headers is request-specific; echo Access-Control-Request-Headers rather than hard-coding

Security considerations

  • NEVER set origin: true (reflect request origin) with credentials: true in production. This is effectively "trust everyone with credentials" — an attacker site can read authenticated responses.
  • NEVER set origin: "*" with credentials: 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 matches evil-example.com.attacker.com. Use URL parsing 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).
  • exposedHeaders leaks header names to JavaScript. Don't expose internal headers like X-Internal-User-Id.
  • Beware null origin. File-protocol pages, sandboxed iframes, and certain redirects send Origin: null. Some configurations match null to the allowlist — explicitly exclude.
  • Use helmet's crossOriginResourcePolicy for additional protection on responses (same-origin / same-site / cross-origin).
  • Preflight cache invalidation. Changing CORS policy doesn't immediately propagate — browsers cached maxAge preflights. Consider maxAge: 0 during 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.

javascript
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

ToolRole
expressHost framework.
@fastify/corsFastify equivalent (different package).
Hono cors()Edge runtimes.
@koa/corsKoa equivalent.
helmetCombine for full HTTP-security middleware stack.
next-corsNext.js wrapper for API routes.
cors-anywherePublic CORS proxy (use only for dev; never expose).
express-rate-limitPair 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 corsorigin(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, Go net/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