CORS Errors Are the Browser Doing Its Job
- 14 May, 2026

I dread CORS in every new project I start. Everything works fine in my local environment, and the moment I hit production, the frontend refuses to talk to the API. I spend hours on Stack Overflow, and more recently Claude and Gemini, trying different fixes until one of them finally works. I’ve never actually sat down and understood how CORS works, I’ve just pattern-matched my way through it project after project. So here’s me trying to actually explain it, instead of just making the error go away again.
Why it only shows up in production
The reason it never happens locally is usually an accident of setup, not anything you did right. Locally, your frontend and backend are often on the same origin already, both on localhost, sometimes literally the same port through a dev server proxy. Same origin means the browser has nothing to check, so CORS never even engages. Your local setup didn’t get CORS right, CORS just never had a reason to show up.
Push to production and that usually changes. Your frontend is on one domain, your API’s on another, or a different subdomain, or a different port, any of which counts as a different origin to the browser, and now it actually has to decide whether the frontend is allowed to read what the API sends back. Production is just the first time your setup actually forces that decision to happen.
What CORS is actually deciding
CORS, Cross-Origin Resource Sharing, is a browser-enforced rule that controls which websites are allowed to read the response your backend sends back. Your API still runs the request either way, the database still gets hit, the logic still executes. The browser is the thing deciding, after the fact, whether the page that asked is actually allowed to see what came back. By default: no, unless you explicitly say otherwise. That’s the distinction I kept missing every time I hit this error: the request succeeds, and the browser is the one deciding afterward whether to hand the response over to whoever asked.
The shortcut that breaks the rule
The failure looks the same almost everywhere it happens, and it’s the fix I always found first. A frontend hits a CORS error in the console, the backend logs origin not allowed, and instead of adding that one origin to a list, it’s much faster to reach for the setting that makes every version of the error disappear at once:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Or worse, and this one took me longer to recognize as the same mistake, the backend just reflects back whatever Origin header the request sent, and calls it allowed:
// This "fixes" every CORS error you'll ever see. That's the problem.
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
That second one reads like it’s doing something more careful than a bare wildcard, since it’s echoing a real value instead of *. It isn’t. It says yes to literally every origin that ever asks, one at a time, which from a security standpoint is the same wildcard wearing a disguise.
What that actually opens up
A user is logged into your app, bank.com, and has a valid session cookie sitting in their browser. They then visit an unrelated page, evil.com, that has nothing to do with you, maybe a link in an email, maybe an ad. That page makes a request to bank.com’s API from JavaScript running in the visitor’s browser.
The browser does something it always does, regardless of which site is asking: it attaches the user’s session cookie for bank.com to that request automatically, because as far as the browser’s concerned, the request is going to bank.com, cookies for bank.com belong on it. Your backend sees a logged-in session and does what it always does, it responds with real data.
The only thing standing between evil.com and that response is whether the browser lets the page on evil.com actually read it. If your CORS config reflects any origin and allows credentials, the answer is yes. No password needed, no SQL injection, no exploit beyond visiting a page. Profile data, contact lists, transaction history, private messages, whatever the endpoint returns to a logged-in user, an attacker’s page can read it, silently, off a session cookie that was never theirs.
Why the shortcut is so tempting
Nobody sets this up on purpose. You spend hours developing and just want the quickest way to get your app into production. Reaching for the setting that makes the CORS error disappear feels like clearing the last obstacle in your way. What actually gets cleared out of the way is the one check that was doing its job, and the setting that makes it stop complaining hands that check over to whoever asks next.
What actually fixes it
Here’s what I wish had been the first search result instead of the tenth workaround:
-
Never combine a wildcard with credentials.
Access-Control-Allow-Origin: *plusAccess-Control-Allow-Credentials: trueis a combination browsers themselves are supposed to reject, but reflecting the origin dynamically gets you the same result while looking like it passed a check. If cookies or credentials are involved anywhere, a wildcard is not an option. -
Use a real allow-list. Keep the actual set of origins your frontend runs on in config, pulled from an env var, not hardcoded and not a wildcard:
ALLOWED_ORIGINS=https://app.example.com,https://admin.example.comCheck the incoming
Originheader against that list on every request, and only echo it back on a match. Adding a new environment means adding a domain to one env var, not touching code. -
Never reflect the origin unconditionally. Reflecting whatever
Originheader shows up is not the same as validating it. If the value isn’t in your allow-list, don’t echo it, and don’t set the header at all. -
Only allow credentials where it’s actually needed. If your frontend authenticates with a cookie instead of a bearer token, both sides have to opt in explicitly: the frontend sets something like
withCredentials: trueon its requests, and the backend has to separately mark that specific origin as trusted to receive the cookie, not just allowed to make requests. Those are two different permissions, and it’s easy to grant the second without meaning to. -
Handle preflight requests properly. Browsers send an
OPTIONSrequest first for anything beyond a simple GET, checking whether the real request would be allowed. Respond to it correctly instead of disabling it or auto-approving everything to make it stop complaining.