CORS, Demystified: Preflights, Credentials, and the Errors That Aren't
CORS is a browser-enforced rule about who may READ a cross-origin response — not a server firewall. Understanding that flips most CORS errors from mysterious to obvious.
CORS produces some of the most misread errors in web development, because almost everyone starts with the wrong mental model. CORS is not a server-side firewall that blocks requests. It is a browser rule that decides whether JavaScript on one origin is allowed to read a response from another origin. Get that one sentence right and the rest stops being mysterious.
1. What CORS actually is (and isn't)
The browser enforces the same-origin policy: by default, script from https://app.example.com may not read responses from a different origin (different scheme, host, or port). CORS — Cross-Origin Resource Sharing — is the controlled relaxation of that policy: the server opts in by sending Access-Control-Allow-Origin and friends, telling the browser "it's fine to let this origin read my response."
Two consequences people miss:
- CORS is enforced by the browser, not the server. Your server-to-server calls,
curl, Postman, and mobile apps are completely unaffected by CORS — there's no browser to enforce it. Ifcurlworks but the browser doesn't, that's CORS, and it's working as designed. - The request usually still reaches your server. For a "simple" request the browser sends it, gets the response, and then — if the CORS headers are missing — blocks your JavaScript from reading it. The side effects already happened. This is why CORS is not a security boundary for the server; it protects the user's data in their browser, not your endpoint.
2. Simple requests vs preflight
The browser splits cross-origin requests into two classes. A simple request (roughly: GET/HEAD/POST, only safelisted headers, and a Content-Type of text/plain, application/x-www-form-urlencoded, or multipart/form-data) is sent directly; the browser just checks the response headers afterward.
Anything else — a PUT/DELETE/PATCH, a custom header like Authorization or X-Api-Key, or Content-Type: application/json — triggers a preflight: the browser first sends an OPTIONS request asking permission, and only sends the real request if the server approves.
# Preflight the browser sends automatically:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# Server must answer with approval:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600
The crucial insight: sending JSON (Content-Type: application/json) is enough to trigger a preflight. That's why a request that worked as a form post suddenly "breaks with CORS" when you switch it to JSON — you've moved it from simple to preflighted, and your server isn't answering the OPTIONS. Access-Control-Max-Age lets the browser cache the preflight result so it isn't repeated on every call.
3. Credentials change all the rules
The moment you send cookies or HTTP auth on a cross-origin request (fetch(url, { credentials: 'include' })), the requirements tighten sharply:
- The server must send
Access-Control-Allow-Credentials: true. Access-Control-Allow-Originmay NOT be*when credentials are included — it must echo the specific requesting origin. The wildcard and credentials are mutually exclusive, by spec.- Likewise
Allow-Headers/Allow-Methodscan't be*in credentialed mode; list them explicitly.
Access-Control-Allow-Origin: https://app.example.com # echoed, never *
Access-Control-Allow-Credentials: true
Vary: Origin # see below
Because you now have to reflect the origin dynamically, you must validate it against an allowlist server-side and add Vary: Origin so caches don't serve one origin's CORS headers to another. Blindly reflecting any Origin back with Allow-Credentials: true is a real vulnerability — it lets any site make credentialed calls as your logged-in user.
4. The errors that aren't what they say
CORS error messages are notoriously misleading. Two in particular:
- "No 'Access-Control-Allow-Origin' header is present" on a request that returns 500. Often the real problem is that your endpoint threw, and the error path didn't attach CORS headers. The browser then reports CORS, hiding the actual 500. Check the Network tab's response, not just the console.
- The preflight
OPTIONSreturns 401/404/405. Many auth middlewares reject the unauthenticatedOPTIONS, or the router has noOPTIONShandler. The browser sees a non-2xx preflight and blocks the real request. Your CORS middleware must answerOPTIONSbefore auth runs and return a 2xx.
A reliable tell: if the failing request never shows up in your server logs but an OPTIONS does (or nothing does), it's a preflight/handler problem, not your business logic.
5. Doing it right on the server
Don't hand-roll header strings. Use your framework's CORS middleware with an explicit allowlist:
// Express example
import cors from 'cors';
const allow = new Set(['https://app.example.com', 'https://admin.example.com']);
app.use(cors({
origin: (origin, cb) => cb(null, !origin || allow.has(origin)),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}));
The middleware handles the OPTIONS preflight, echoes the validated origin, and sets Vary: Origin for you. The two rules to never break: validate the origin against an allowlist (don't reflect arbitrary origins), and never combine * with credentials.
6. When CORS isn't the right tool at all
If a browser app calls many third-party APIs, or you want to avoid exposing keys, a same-origin proxy sidesteps CORS entirely: your frontend calls your own backend (same origin, no CORS), and the backend calls the third party (server-to-server, no browser, no CORS). This is also how you keep secrets out of the client. CORS is for when the browser must talk cross-origin directly and the other server wants to allow it.
Rules of thumb
- CORS gates READING a cross-origin response in the browser — it's not a server firewall and doesn't protect your endpoint.
curl/server-to-server ignore it entirely. - JSON bodies, custom headers, and non-simple methods trigger a preflight
OPTIONSyour server must answer with a 2xx, before auth middleware runs. - Credentials forbid
*. Echo the specific allowed origin, setAllow-Credentials: trueandVary: Origin, and validate against an allowlist. - A CORS error on a 500/401 is often a mask — read the actual response in the Network tab before debugging CORS.
- Use framework middleware, not hand-written headers, and when the browser doesn't need to call cross-origin directly, a same-origin proxy avoids CORS (and hides your keys).