Skip to content
Express.js ex security 4 min read

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: * with Access-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

OptionTypePurpose
originstring | string[] | RegExp | functionWhich origins are allowed; reflects the request origin when permitted
methodsstring | string[]HTTP methods returned in Access-Control-Allow-Methods
allowedHeadersstring[]Request headers the client may send
exposedHeadersstring[]Response headers JavaScript is allowed to read
credentialsbooleanSends Access-Control-Allow-Credentials: true for cookies/auth
maxAgenumberSeconds 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 origin so unknown sites are rejected, and keep the list in config, not hard-coded inline.
  • Pair credentials: true with a concrete origin; the wildcard * is invalid with credentials and silently breaks the browser.
  • Restrict methods and allowedHeaders to 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 maxAge to reduce preflight chatter, and serve everything over HTTPS so origins are trustworthy.
Last updated June 14, 2026
Was this helpful?