cd ../writing
// web security · CORS

CORS, finally explained — the mental model that sticks.

CORS error messages are some of the worst in web development. "Access-Control-Allow-Origin missing" — useful if you understand CORS, baffling if you don't. The problem is everyone learns CORS by trial and error against opaque errors. This guide gives you the actual mental model: what CORS is protecting against, when preflight happens, why "simple requests" are different, and the credentials gotcha that breaks production.

2 request types 5 headers that matter 1 credentials trap © use freely

01What CORS actually protects

Before CORS, there was the Same-Origin Policy: a browser would not let JavaScript on site-a.com read responses from site-b.com. This prevented a malicious site from making your browser fetch your bank account data and reading the response.

CORS (Cross-Origin Resource Sharing) is the escape hatch. It lets site-b.com opt in to allowing site-a.com to read its responses. The mechanism: the browser sends a request with an Origin: site-a.com header. The server responds with Access-Control-Allow-Origin: site-a.com (or *). The browser then allows the JavaScript to read the response.

Crucially: CORS is enforced by the browser, not the server. The request reaches the server either way. The browser just refuses to let the calling JavaScript read the response if the server didn't say "yes." So CORS doesn't protect your API from non-browser clients — those bypass it entirely.

02What counts as the same origin

An origin is the combination of scheme + host + port. All three must match:

  • https://example.com and https://example.com/path — same origin ✓
  • https://example.com and http://example.com — different (scheme) ✗
  • https://example.com and https://api.example.com — different (host) ✗
  • https://example.com and https://example.com:8080 — different (port) ✗

Subdomains are different origins. www.example.com and api.example.com need CORS to talk to each other from the browser.

03Simple requests — no preflight

Some requests are considered "simple" and skip the preflight check. They go directly to the server, and the browser checks the response for the CORS header afterward.

A request is simple if all of these are true:

  • Method is GET, HEAD, or POST
  • Only "safe" headers (Accept, Accept-Language, Content-Language, Content-Type, plus a few others)
  • Content-Type is one of: application/x-www-form-urlencoded, multipart/form-data, or text/plain

Notice what's NOT in the list: application/json. The moment you send JSON, your request is no longer simple, and the browser will preflight.

04The preflight request

For non-simple requests, the browser sends an OPTIONS request first to ask the server: "are you willing to accept the actual request I'm about to send?" The server responds with the allowed methods, headers, and origins.

✓ preflight request
OPTIONS /api/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
✓ preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

The server is saying: "yes, I accept POST requests from app.example.com with Content-Type and Authorization headers. Don't bother asking again for the next 24 hours."

Only after a successful preflight does the browser send the actual request. If the preflight fails, the actual request never happens.

05The five headers that matter

  • Access-Control-Allow-Origin — which origin can read responses. Can be a specific origin or *.
  • Access-Control-Allow-Methods — which HTTP methods are allowed (for preflight).
  • Access-Control-Allow-Headers — which non-simple headers the client can send (for preflight).
  • Access-Control-Allow-Credentials — boolean. If true, the browser will include cookies and auth headers, AND the response can be read by credentialed requests.
  • Access-Control-Max-Age — how long the browser can cache the preflight (seconds).

06The credentials trap

By default, cross-origin requests don't send cookies. Even if the user has a session cookie for api.example.com, a request from app.example.com won't include it unless you opt in.

Opting in:

✓ client opts in to sending credentials
fetch('https://api.example.com/users', {
  credentials: 'include'     // or 'same-origin' (default), 'omit'
});

But there's a trap. When the request uses credentials, the server's Access-Control-Allow-Origin cannot be * — it must be a specific origin. And the server must also send Access-Control-Allow-Credentials: true.

✗ this combination is rejected by the browser
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
✓ for credentialed requests, echo the origin
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

The Vary: Origin header is important when echoing the origin back — it tells caches that the response varies based on the request's Origin header. Without it, a cache might serve the wrong origin's response to a different origin's request.

07Server implementation patterns

Most CORS implementations look like:

✓ Express middleware
import cors from 'cors';

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400
}));

Three patterns worth remembering:

  • Whitelist origins explicitly. Don't use * in production unless you have a public read-only API that takes no credentials.
  • Echo origin from a list. If you need to allow multiple origins, check the request's Origin against an allowlist and echo it back.
  • Set Access-Control-Max-Age. Without it, every non-simple request triggers a preflight. With 24 hours, each preflight covers 24 hours of subsequent requests.

08Reading CORS error messages

The browser console shows CORS errors but the requests look successful in Network tab. That's because the request DID succeed — the browser is just hiding the response from JavaScript.

Common errors decoded:

  • "No 'Access-Control-Allow-Origin' header is present" — server didn't send the CORS header at all. Server-side fix.
  • "The 'Access-Control-Allow-Origin' header has a value 'X' that is not equal to the supplied origin" — server is allowing a different origin. Update server allowlist.
  • "Request header 'X' is not allowed by Access-Control-Allow-Headers" — server's preflight response didn't include this header. Add it.
  • "The value of the 'Access-Control-Allow-Credentials' header is '' which must be 'true'" — credentials requested but server didn't allow them. Update server config.
  • "Preflight response is not successful. Status code: 401" — your auth middleware is requiring a token on the OPTIONS request. Skip auth for OPTIONS.

09Common bypass attempts that don't work

Things developers try when stuck on CORS that don't actually fix it:

  • "Just turn off CORS in Chrome." Works locally, breaks for users. CORS is a browser feature; users have it enabled.
  • JSONP. Old workaround, security nightmare, do not use.
  • "Proxy through my server." This actually works but moves the problem. You're paying for the bandwidth and latency of every API call.

The right fix is server-side CORS headers. Everything else is a hack.

The summary

CORS is the browser's mechanism for letting servers opt in to cross-origin reads. It involves an Origin header on the request, CORS headers on the response, and sometimes a preflight OPTIONS request before non-simple requests.

The mental model: when the browser sees JS trying to read a response from a different origin, it checks whether the server said "yes." If yes, the JS reads the response. If no, the browser hides it.

Once you have the mental model, the error messages become readable, the configuration becomes obvious, and you stop spending hours on what should be a 5-minute fix.