Configuring CORS Correctly
Cross-Origin Resource Sharing (CORS) is a browser security mechanism that controls whether a web page served from one origin may read responses from an API on a different origin. By default the browser’s same-origin policy blocks such reads; CORS is the opt-in protocol your server uses to say “this origin is allowed.” Getting it right is subtle — the rules differ for “simple” and “preflighted” requests, and the convenient wildcard configuration is exactly the one you must not ship when credentials are involved. This page explains the protocol and shows how to configure it safely in Node.js.
What an origin is
An origin is the triple of scheme, host, and port: https://app.example.com:443. Two URLs share an origin only if all three match. https://app.example.com and https://api.example.com are different origins (different host), and so are http://localhost:3000 and http://localhost:5173 (different port). When front-end JavaScript calls an API on a different origin via fetch or XMLHttpRequest, CORS applies.
CORS is enforced by the browser, not by your server. A request from
curl, a mobile app, or another backend ignores CORS entirely — it is not a substitute for authentication or authorization.
Simple requests vs preflight
The browser classifies cross-origin requests into two kinds. A simple request (a GET, HEAD, or POST using only safe headers and a content type of text/plain, application/x-www-form-urlencoded, or multipart/form-data) is sent straight to the server with an Origin header. The browser then checks the response’s Access-Control-Allow-Origin header before exposing the body to your script.
Anything else — a PUT/DELETE/PATCH, a JSON body (Content-Type: application/json), or a custom header like Authorization — triggers a preflight. The browser first sends an OPTIONS request asking permission, and only sends the real request if the server approves.
# Preflight request the browser sends automatically
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# Server response that grants permission
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
The Access-Control headers
The server controls everything through response headers. These are the ones that matter:
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin | The single origin allowed to read the response, or * for any. |
Access-Control-Allow-Methods | HTTP methods permitted (answered on preflight). |
Access-Control-Allow-Headers | Request headers the client may send (answered on preflight). |
Access-Control-Allow-Credentials | true to allow cookies/Authorization to be sent. |
Access-Control-Expose-Headers | Response headers the client’s JS is allowed to read. |
Access-Control-Max-Age | Seconds the browser may cache the preflight result. |
Vary: Origin | Tells caches the response differs per origin. |
Configuring the cors middleware
In Express the cors middleware handles all of this — including answering preflight OPTIONS requests — so you rarely write the headers by hand.
npm install cors
The default cors() call reflects Access-Control-Allow-Origin: *, which is fine only for a fully public, credential-free API. For a real app, pin an explicit allow-list of origins instead.
import express from "express";
import cors from "cors";
const app = express();
const allowedOrigins = new Set([
"https://app.example.com",
"http://localhost:5173", // local dev front-end
]);
const corsOptions = {
origin(origin, callback) {
// `origin` is undefined for same-origin or non-browser requests
if (!origin || allowedOrigins.has(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 600, // cache preflight for 10 minutes
};
app.use(cors(corsOptions));
app.get("/orders", (req, res) => res.json({ orders: [] }));
app.listen(3000, () => console.log("API on http://localhost:3000"));
When the origin function returns an error, the middleware skips the CORS headers and the browser blocks the response — which is the behaviour you want for a disallowed origin.
In CommonJS the only difference is the import: const cors = require("cors");.
Allowing credentials
Cookies, TLS client certificates, and the Authorization header are not sent on cross-origin requests unless the client sets credentials: "include" and the server returns Access-Control-Allow-Credentials: true. There is one hard rule the spec enforces: when credentials are allowed, Access-Control-Allow-Origin may not be * — it must echo the exact requesting origin.
// Front-end: opt in to sending cookies
const res = await fetch("https://api.example.com/orders", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sku: "A-1" }),
});
The origin callback above already echoes a specific origin, so it composes correctly with credentials: true. Always add Vary: Origin (the cors package does this automatically) so a CDN doesn’t serve one origin’s allow header to another.
Common misconfigurations
origin: "*"withcredentials: true. The browser silently rejects this combination; cookies never arrive. Echo the specific origin instead.- Reflecting any origin you receive. Code that copies
req.headers.originstraight into the allow header trusts every site on the internet — effectively disabling CORS while looking secure. - Forgetting preflight. If
OPTIONSisn’t answered (e.g. a route guard runs first and401s the preflight), the real request never fires. Mountcorsbefore auth middleware. - Treating CORS as authorization. A relaxed CORS policy doesn’t grant access; a strict one doesn’t protect data from non-browser clients. Keep real auth checks on every route.
Output (browser console for a blocked origin):
Access to fetch at 'https://api.example.com/orders' from origin
'https://evil.example' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Best Practices
- Maintain an explicit allow-list of origins; never ship
Access-Control-Allow-Origin: *for an API that returns user data or uses credentials. - Mount the
corsmiddleware before authentication so preflightOPTIONSrequests are answered, not rejected. - Set
credentials: trueonly when you genuinely need cookies orAuthorization, and always pair it with an exact-origin reflection, never*. - List only the methods and headers your API actually accepts — a tight
allowedHeadersreduces surface area. - Add a sensible
Access-Control-Max-Ageto cache preflights and cut latency, but keep it short enough to roll out policy changes quickly. - Keep
Vary: Originon responses so shared caches and CDNs don’t leak one origin’s permission to another. - Remember CORS is browser-only — enforce authentication and authorization independently on every endpoint.