Back to blog
Mar 2026·3 min readAI-generated

Understanding CORS Once and For All

#web#http#security

CORS errors show up in the console at the worst possible time, say almost nothing useful about how to fix them, and get "solved" by copy-pasting Access-Control-Allow-Origin: * from Stack Overflow without anyone understanding what just happened. Here's what's actually going on, and why the browser is doing this to you on purpose.

It's not a server problem — it's a browser problem

The first thing to internalize: CORS (Cross-Origin Resource Sharing) is enforced entirely by the browser, not the server. If you curl an endpoint that "has a CORS error" in the browser, it works fine — because curl doesn't enforce CORS at all. The server always sends the response; the browser is the one that decides whether to let your JavaScript read it.

This exists because of the same-origin policy: by default, a script running on https://app.example.com cannot read responses from https://api.other.com, even though it can technically send the request. Without this, any website you visit could silently make authenticated requests to your bank on your behalf, using cookies your browser sends automatically. CORS is the mechanism that lets a server explicitly opt certain other origins into being allowed to read its responses.

Origins, precisely

Two URLs share an origin only if scheme, host, and port all match exactly.

https://app.example.com:443/page1
https://app.example.com:443/page2   → same origin (path doesn't matter)
http://app.example.com               → different origin (scheme differs)
https://api.example.com              → different origin (host differs)
https://app.example.com:8080         → different origin (port differs)

localhost:3000 talking to localhost:4000 during development is a cross-origin request, which is why local dev setups hit CORS constantly even though "it's all localhost."

Simple requests vs preflighted requests

Not every cross-origin request triggers the same browser behavior. A request qualifies as "simple" — and skips the extra round trip — only if it meets fairly narrow conditions: method is GET, HEAD, or POST; only a small allowlist of headers is set (Accept, Accept-Language, Content-Language, Content-Type); and Content-Type is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.

Anything else — a PUT, a custom header like Authorization or X-Api-Key, a JSON body with Content-Type: application/json — triggers a preflight: the browser sends an OPTIONS request first, asking permission, before sending the real one.

OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

The server has to answer that preflight explicitly:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

Access-Control-Max-Age matters for performance: it tells the browser how long, in seconds, it can cache this preflight result before asking again. Without it, every PUT to a JSON API pays for two requests instead of one.

A minimal Express server that gets it right

app.use((req, res, next) => {
  const allowedOrigins = ['https://app.example.com', 'http://localhost:3000'];
  const origin = req.headers.origin;

  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }

  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Max-Age', '86400');

  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }
  next();
});

The Vary: Origin header matters if this response might be cached anywhere (a CDN, a shared proxy) — it tells the cache that the response body depends on the request's Origin header, so it shouldn't serve a response meant for one origin to a request from a different one.

Credentials are opt-in, twice

If a request needs to send cookies or an Authorization header for a session that lives cross-origin, both sides have to explicitly agree to it:

// Client
fetch('https://api.example.com/orders', {
  credentials: 'include',
});
// Server response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

Two things break here constantly: Access-Control-Allow-Origin: * is explicitly disallowed by the spec when credentials are involved — you must echo back a specific origin, not a wildcard — and forgetting Access-Control-Allow-Credentials: true on the server means the browser silently discards the response even though the request itself succeeded.

Why * isn't just a lazy shortcut

Access-Control-Allow-Origin: * tells the browser "any website on the internet may read this response." For a genuinely public, unauthenticated API (a weather API, a public CDN asset) that's fine. For anything that returns user-specific or authenticated data, it's a way of undoing the same-origin policy's entire purpose — any site the user visits could now read that data via a background fetch, if credentials were somehow attached. This is why credentialed requests block the wildcard outright at the spec level, rather than trusting developers not to combine the two.

Takeaway

CORS isn't a bug in your server or an obstacle the browser throws up arbitrarily — it's the browser enforcing that cross-origin reads require explicit, per-origin opt-in from the server being read from. Simple requests get a lightweight header check; anything with a custom header, non-trivial method, or JSON body gets a preflight first. Get the allowed origin, the allowed headers, and the credentials flag right, and the errors disappear because there's nothing left for the browser to object to.

NewerStructured Logging: Why printf Debugging Doesn't ScaleOlderRate Limiting Algorithms: Token Bucket vs Sliding Window