CORS

intermediate express cors security

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:

Simple request
GET / POST / HEAD
+ only safe headers
+ Content-Type: form or text/plain

Browser sends it directly, then checks response headers.
Preflighted request
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

1. Browser: OPTIONS /api/users
   Origin: https://app.example.com
   Access-Control-Request-Method: POST
   Access-Control-Request-Headers: content-type
2. Server: 204 No Content
   Access-Control-Allow-Origin: https://app.example.com
   Access-Control-Allow-Methods: POST
   Access-Control-Allow-Headers: content-type
3. Browser: 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:

  1. credentials: true on the server.
  2. Access-Control-Allow-Origin must 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 Authorization to allowedHeaders.
  • “…wildcard cannot be used with credentials” — fix listed above.
  • Preflight returns 404 — our app routes OPTIONS to 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.