Configuring CORS
Browsers enforce the same-origin policy: by default a page served from https://app.example.com cannot read responses from https://api.example.com because they differ in scheme, host, or port. Cross-Origin Resource Sharing (CORS) is the controlled exception to that rule — a set of HTTP headers your server sends to opt in to specific cross-origin callers. Getting CORS right matters because it is the line between a friendly browser frontend reaching your API and a malicious site quietly doing the same. This page shows how to configure the cors middleware in Express to allowlist origins, handle preflight requests, and support credentials without opening dangerous holes.
How CORS actually works
CORS is enforced by the browser, not your server. When JavaScript on one origin calls another, the browser inspects the response’s Access-Control-Allow-Origin header and blocks the script from reading the body if the origin is not permitted. The request often still reaches your server — CORS protects the response, not the endpoint — so CORS is never a substitute for authentication or authorization.
For “non-simple” requests (anything using PUT, DELETE, custom headers, or a JSON content type), the browser first sends a preflight OPTIONS request asking whether the real request is allowed. Your server must answer that preflight with the right headers before the browser will send the actual call.
Installing and using the cors middleware
Install the package and mount it. With no options it reflects every origin (Access-Control-Allow-Origin: *), which is fine for a truly public, credential-free API but too permissive for most apps.
npm install cors
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors()); // ⚠️ allows ALL origins — tighten this in production
app.use(express.json());
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
app.listen(3000);
Allowlisting specific origins
The single most important option is origin. Pass an exact string, an array, or a function for dynamic checks. A function lets you validate against an allowlist and reject everything else, which is the safest pattern.
const allowed = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use(
cors({
origin(origin, callback) {
// `origin` is undefined for same-origin or non-browser (curl) requests
if (!origin || allowed.has(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);
Output:
$ curl -i -H "Origin: https://app.example.com" https://api.example.com/api/health
HTTP/2 200
access-control-allow-origin: https://app.example.com
vary: Origin
content-type: application/json
{"status":"ok"}
Note the Vary: Origin header — cors adds it automatically when the allowed origin is computed dynamically, so caches do not serve one origin’s response to another.
Handling preflight requests
The cors middleware answers preflight OPTIONS requests automatically when mounted with app.use. For a single route you can also attach it explicitly and pre-register the OPTIONS handler. In Express 5 the path-matching syntax changed, so use a named wildcard instead of a bare *.
const corsOptions = {
origin: "https://app.example.com",
allowedHeaders: ["Content-Type", "Authorization"],
};
// Express 4: app.options("*", cors(corsOptions))
// Express 5: use a named splat parameter
app.options("/{*splat}", cors(corsOptions));
app.delete("/api/posts/:id", cors(corsOptions), async (req, res) => {
await db.posts.delete(req.params.id);
res.status(204).end();
});
A successful preflight returns 204 No Content with the allow headers:
Output:
$ curl -i -X OPTIONS https://api.example.com/api/posts/42 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: DELETE"
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET,POST,PUT,DELETE
access-control-allow-headers: Content-Type,Authorization
Credentials and the wildcard trap
If your frontend sends cookies or Authorization headers across origins, it must set fetch(url, { credentials: "include" }), and your server must respond with Access-Control-Allow-Credentials: true. Set credentials: true in the middleware options.
app.use(
cors({
origin: "https://app.example.com", // must be a concrete origin, never "*"
credentials: true,
})
);
Warning: The CORS spec forbids combining
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true. Browsers will reject the response. When credentials are involved you must echo back a specific origin — which is exactly why the function-based allowlist above is the right default.
Common options reference
| Option | Type | Purpose |
|---|---|---|
origin | string | string[] | RegExp | function | Which origins are allowed; reflects the request origin when permitted |
methods | string | string[] | HTTP methods returned in Access-Control-Allow-Methods |
allowedHeaders | string[] | Request headers the client may send |
exposedHeaders | string[] | Response headers JavaScript is allowed to read |
credentials | boolean | Sends Access-Control-Allow-Credentials: true for cookies/auth |
maxAge | number | Seconds the browser may cache the preflight result |
Setting maxAge (for example maxAge: 86400) lets browsers skip the preflight for a day, cutting a round trip off every non-simple request.
Best Practices
- Never ship
cors()with no options to a production API that uses cookies or auth — explicitly allowlist origins. - Use a function or array for
originso unknown sites are rejected, and keep the list in config, not hard-coded inline. - Pair
credentials: truewith a concrete origin; the wildcard*is invalid with credentials and silently breaks the browser. - Restrict
methodsandallowedHeadersto what your API actually uses instead of allowing everything. - Remember CORS is browser-enforced — back it with real authentication, authorization, and input validation on the server.
- Set a sensible
maxAgeto reduce preflight chatter, and serve everything over HTTPS so origins are trustworthy.