CORS for browser clients

Express · Practical · 11 min

Your API is unusable from a front-end on another origin until CORS is set up — and dangerous if set up carelessly.

Browsers enforce the same-origin policy: JavaScript on https://app.example.com may send a request to https://api.example.com, but cannot read the response unless the API says so with CORS headers. CORS is a browser rule; curl, servers and mobile apps ignore it. It is not authentication — it controls which websites' scripts may read your responses.

The mechanics:

  • For simple requests (GET/POST with basic headers), the browser sends the request with an Origin header and checks the response's Access-Control-Allow-Origin.
  • For anything else — PUT/DELETE, Content-Type: application/json, an Authorization header — the browser first sends a preflight OPTIONS request asking permission (Access-Control-Request-Method, -Headers). Your server must answer it with the allowed methods and headers.
  • To send cookies, the client sets credentials: "include" and the server must return Access-Control-Allow-Credentials: true with a specific origin — * is not allowed with credentials.

The cors package handles all of this:

  • cors({ origin: ["https://app.example.com"] }) — allow a fixed list (it echoes the matching origin and sets Vary: Origin).
  • origin can also be a function for dynamic checks, e.g. per-tenant domains.
  • credentials: true, methods, allowedHeaders, exposedHeaders (to let JS read custom response headers), maxAge (cache the preflight).

Mount it before your routes so preflights get answered. Avoid reflecting any origin with credentials — that lets every website make authenticated requests on your users' behalf. If front-end and API share an origin (via a reverse proxy), you don't need CORS at all.

Example

import express from "express";
import cors from "cors";

const app = express();

const allowed = new Set(["https://app.example.com", "http://localhost:5173"]);

app.use(cors({
  origin(origin, callback) {
    // Non-browser clients send no Origin header; allow them (CORS doesn't apply)
    if (!origin || allowed.has(origin)) return callback(null, true);
    callback(null, false);                    // no CORS headers -> browser blocks the read
  },
  credentials: true,                          // allow cookies; requires a specific origin
  methods: ["GET", "POST", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  maxAge: 600,                                // cache preflight for 10 minutes
}));

app.use(express.json());
app.patch("/api/profile", (req, res) => res.json({ ok: true }));

app.listen(3000);
// Preflight: curl -i -X OPTIONS localhost:3000/api/profile \
//   -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: PATCH"

Remember

  • CORS lets chosen origins' scripts read your responses; it isn't auth
  • Non-simple requests trigger an OPTIONS preflight you must answer
  • Credentials require a specific origin, never *
  • Mount cors() before routes; allow-list origins

Watch out for

  • Using origin: true (reflect any origin) together with credentials: true.

Check yourself

  1. A React app on localhost:5173 sends fetch(api, { method: 'PUT', headers: { 'Content-Type': 'application/json' } }). What does the browser send first?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/resources/middleware/cors