CORS is a browser feature, not a server guard. Your server always answers; CORS headers only tell the browser whether the page that asked may read the answer. curl 3,008 , a mobile app and a Node script ignore CORS entirely. That explains the two common mistakes: believing Access-Control-Allow-Origin protects data, and setting it to * on an API that uses cookies. The cors 6,195 package (MIT, expressjs/cors (https://github.com/expressjs/cors 6,195 ), 2.8.6) handles the preflight OPTIONS request and the headers on the real response. Prefer an allowlist function over a string: it can consult a database and lets through the Origin-less requests that server-to-server calls send.
const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
app.use(cors({
origin(origin, cb) {
if (!origin || allowed.has(origin)) return cb(null, true);
cb(null, false); // no header: the browser blocks the read
},
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 600 // cache the preflight for 10 minutes
}));OPTIONS /api/books Origin: https://app.example.com (+ the two request headers)
204 access-control-allow-origin: https://app.example.com access-control-max-age: 600
access-control-allow-credentials: true vary: Origin
access-control-allow-headers: Content-Type,Authorization
access-control-allow-methods: GET,POST,PATCH,DELETE
OPTIONS /api/books Origin: https://evil.example
200 allow: GET, HEAD, POST (no access-control-* header at all)
GET /api/books Origin: https://evil.example
200 {"items":["Dune"]} (no access-control-* header at all)Read the third response carefully: the refused origin still received the books. Without Access-Control-Allow-Origin a browser throws the body away before the page sees it - but the bytes crossed the network. CORS is not authorization. A private /api/books needs a token or session check (Cookies and Auth).
The refused preflight is informative too: with no CORS headers to add, it falls through to Express 24,430 's built-in OPTIONS responder, which answers 200 with an Allow list - so a 200 in your logs is not proof a preflight succeeded. Note also that credentials: true and origin: '*' are mutually exclusive by specification, and that vary: Origin stops a shared cache handing one origin's allow-origin header to another. Mount cors on the router that needs it, not the whole app: an /internal router without it is unreachable from any page.