Security

CORS is not protecting you

CORS is a browser rule — nothing outside a browser has to obey it. Here's what it actually does, what it doesn't, and where the real security work lives.

·7 min read·Samson Ameh
#Security#CORS#Express#Web

CORS is not protecting you

I've built a few personal projects with backend included, and on the frontend side I've also run into plenty of CORS issues on APIs other backend devs own. Every time, my instinct was the same: test the endpoint outside the browser and see what actually happens.

If the server was really the one blocking those requests, it should've rejected that too. It didn't.

Terminal
$curl https://api.myshop.com/orders

Same endpoint, no browser involved, and it just returns the data. No error, no rejection, nothing.

The server was never doing the blocking. The browser was.

Take an Express API like this:

typescript
app.get("/orders", (req: Request, res: Response) => {
  res.json({ orders: [] });
});

No CORS middleware, no origin check anywhere in the route. It just responds to whoever asks. The browser is the one deciding whether to let the calling page see that response.

CORS doesn't stop a request from reaching your server. It decides whether the browser lets your JavaScript read the response that comes back. The request still goes out. The server still processes it. The only thing that changes is whether your frontend code is allowed to see the result.

So what is it actually protecting?

Say you're logged into a dashboard built on Express, with a session cookie sitting in your browser. You open an unrelated tab, and a script on that page quietly calls your dashboard's API using your session.

javascript
fetch("https://mydashboard.com/api/profile", { credentials: "include" })
  .then(res => res.json())
  .then(profile => leak(profile));

Your Express server responds, because as far as it's concerned the session cookie is valid and the request looks legit. But your browser checks that response, sees the unrelated tab's origin isn't in your cors() config, and blocks the script from reading it.

javascript
app.use(cors({
  origin: "https://mydashboard.com",
  credentials: true
}));

That config is the only reason the leak got stopped. Not the route logic, not auth, just the browser enforcing what cors() told it to allow.

Here's where it gets uncomfortable

CORS only blocks reading. It says nothing about acting.

If that same unrelated tab isn't trying to read a response and just wants to trigger an action — like updating your account — CORS won't save you. The request still fires. Your Express route still processes it. The damage is done before CORS ever gets a chance to block anything.

javascript
fetch("https://mydashboard.com/api/account/email", {
  method: "POST",
  credentials: "include",
  body: JSON.stringify({ email: "attacker@evil.com" }),
  headers: { "Content-Type": "application/json" }
});

cors() never even gets consulted here, because the attacker's page isn't trying to read the response. It just needs the request to go through, and it does.

The fix lives elsewhere

Fixing this isn't a CORS problem, so the fix doesn't belong in cors() either. In Express, it starts with the session cookie itself:

javascript
app.use(session({
  cookie: {
    sameSite: "strict",
    secure: true
  }
}));

sameSite: "strict" tells the browser not to attach that cookie on requests coming from another site in the first place. No cookie, no valid session, no request worth processing. That's the layer that actually stops CSRF. CORS was never it.

What cors() actually tells you

cors() configured correctly tells you exactly one thing: another website's JavaScript can't read responses from your API in a user's browser. That's the whole scope of it.

It says nothing about CSRF, because CORS never inspects whether a request should be trusted — only whether the response gets read afterward. It says nothing about someone hitting your API directly with curl or a script, because CORS is a browser rule and nothing outside a browser has to obey it. It says nothing about XSS, SQL injection, broken auth, or any of the actual ways APIs get compromised, because none of those live in the cross-origin-response-reading lane CORS was built for.

Get CORS right and you've secured one narrow thing. Your session cookies, your CSRF checks, your input validation, your auth logic — that's a separate job, and it doesn't get done just because cors() looks clean.

If you're a backend engineer, this is the one to sit with: a correct CORS setup tells you nothing about whether your API is actually secure. It only tells you your browser rules are correct.

Be trustworthy. Don't take your users' trust for granted.

Originally posted on LinkedIn

View post →

Written by

Samson Ameh

Frontend Engineer specialising in React, TypeScript, and enterprise UI systems. Based in Lagos, Nigeria.