CORS stands for Cross-Origin Resource Sharing. In simple language: the browser refuses to let JavaScript on app.example.com read responses from api.example.com unless the API explicitly says “yes, I trust that origin.” It’s the browser protecting users, not the server protecting itself.
A common misconception: CORS errors are NOT a server error. The server happily returns 200 OK. The browser then sees no Access-Control-Allow-Origin header and throws the response away before our JS can see it.
What counts as cross-origin
An “origin” is the combo of scheme + host + port. Anything different = cross-origin.
https://app.example.com:443 ← origin A
http://app.example.com:443 ← different (scheme)
https://api.example.com:443 ← different (host)
https://app.example.com:8080 ← different (port)
Two flavors of CORS requests
The browser splits cross-origin requests into two buckets:
GET / POST / HEAD
+ only safe headers
+ Content-Type: form or text/plain
Browser sends it directly, then checks response headers.
PUT / DELETE / PATCH
or custom headers
or Content-Type: application/json
Browser sends OPTIONS first to ask permission.
Any modern JSON API hits the preflight path — Content-Type: application/json alone triggers it.
The preflight dance
OPTIONS /api/usersOrigin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
204 No ContentAccess-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type
POST /api/users (the real request)
The preflight is cacheable (Access-Control-Max-Age), so we don’t pay this cost on every request.
Using the cors middleware
npm install cors
The lazy version — allow everything (fine for public APIs, dangerous if we use cookies):
const cors = require("cors");
app.use(cors()); // Access-Control-Allow-Origin: *
The grown-up version — only allow specific origins:
app.use(cors({
origin: ["https://app.example.com", "https://admin.example.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true, // allow cookies/auth headers
maxAge: 86400, // cache preflight for 1 day
}));
Dynamic origin check — useful for whitelisting from a DB or env var:
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",");
app.use(cors({
origin: (origin, callback) => {
// origin is undefined for same-origin and non-browser clients (curl)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
}));
The credentials: true gotcha
If our frontend sends cookies or auth headers cross-origin (fetch(url, { credentials: "include" })), we need:
credentials: trueon the server.Access-Control-Allow-Originmust be a specific origin, not*. Wildcard + credentials is forbidden by the spec.
// This combo will FAIL in the browser:
app.use(cors({ origin: "*", credentials: true })); // wildcard + creds = no
// This works:
app.use(cors({ origin: "https://app.example.com", credentials: true }));
Per-route CORS
We can apply CORS to specific routes instead of globally:
const publicCors = cors({ origin: "*" });
const privateCors = cors({ origin: "https://admin.example.com", credentials: true });
app.get("/api/health", publicCors, (req, res) => res.json({ ok: true }));
app.use("/api/admin", privateCors, adminRouter);
Common errors and what they mean
- “No ‘Access-Control-Allow-Origin’ header” — we forgot the middleware, or the origin isn’t whitelisted.
- “…header field authorization is not allowed” — add
AuthorizationtoallowedHeaders. - “…wildcard cannot be used with credentials” — fix listed above.
- Preflight returns 404 — our app routes
OPTIONSto a real handler.cors()should handle it before our routes do.
CORS isn’t security — anyone can curl our API. It’s just the browser respecting same-origin policy for its users.