Understanding CORS Once and For All
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.