<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Browser Trust

Browser Trust

The last two attacks don't exploit bugs in your server code — they exploit how the browser decides which websites can talk to each other. That decision starts with the same-origin policy (SOP) : a page from one origin (the combination of scheme, host, and port) can't read responses from a different origin. It's the browser's default wall between sites.

Both topics in this lesson are about holes in that wall. CORS is a hole the server opens deliberately, to let specific origins through. CSRF is an attacker squeezing through a gap that was there all along.

Why does the wall exist at all? Because your browser carries your logged-in sessions with it everywhere. Without SOP, any page you visit — say evil.com — could quietly fetch("https://yourbank.com/api/account") and read the response, because the browser would attach your bank cookies to that request. SOP is what makes it safe for credentials and open tabs to coexist: sites can't read each other's data through your browser. Everything in this lesson is about when and how that guarantee bends.

CORS

The same-origin policy blocks app.example.com from reading a response from api.example.com, even though you own both. CORS (Cross-Origin Resource Sharing) is how the server grants the browser permission to relax that, by sending headers that name the origins allowed to read its responses.

The crucial misconception to clear up: CORS does not protect your server. It's not a server-side access control — it's the server telling the browser whose script is allowed to read the response. A non-browser client (curl, your backend, an attacker's script) ignores CORS entirely. Real protection is authentication and authorization on the endpoint; CORS only governs which web origins a browser will hand the response to.

// Allow one specific origin to read responses. Never reflect arbitrary origins,
// and never combine "*" with credentials.
app.use((req, res, next) => {
  // Name the one origin whose scripts may read this response
  res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
  // Let the browser include cookies/auth on those cross-origin requests
  res.setHeader("Access-Control-Allow-Credentials", "true");
  next();
});

Not every cross-origin request is sent immediately. If a request uses custom headers, a JSON body, or a method like PUT or DELETE, the browser first asks permission: it sends a preflight OPTIONS request, and only sends the real request if your server's Access-Control-Allow-* headers approve the method and origin.

Requests that a plain HTML form could produce — like a form-encoded POST — skip the preflight and go straight to your server. That's the loophole CSRF lives in: the attack fires before CORS ever gets a say.

CORS in local development

The most common place developers first run into CORS is local development: a frontend dev server on localhost:5173 calling a backend on localhost:4000. Different ports mean different origins, so the browser blocks the response. There are two ways to fix this without loosening production security.

Option 1: dev proxy (preferred). Configure your frontend build tool to proxy API requests. The browser only ever talks to localhost:5173, so no cross-origin request happens and CORS never fires. This is the cleanest approach because it removes the problem entirely:

// vite.config.js: the browser sees localhost:5173/api/...,
// Vite rewrites it to localhost:4000/api/... server-side.
// Same origin from the browser's perspective, so CORS never fires.
export default {
  server: {
    proxy: {
      "/api": "http://localhost:4000",
    },
  },
};

Create React App, Next.js, Webpack Dev Server, and Parcel all have an equivalent proxy option. The proxy only runs during npm run dev. It has no effect on the production build, so there is nothing to misconfigure in production.

Option 2: environment-scoped allowlist. When you need genuine cross-origin access (multiple distinct frontend domains consuming the same API), drive the allowed origins from an environment variable. The server checks the incoming Origin against the set and echoes it back only if it matches. Never blindly reflect arbitrary origins, which would be equivalent to *:

// Read the allowed origins from the environment so dev and prod differ.
// .env.development → ALLOWED_ORIGINS=http://localhost:5173
// .env.production  → ALLOWED_ORIGINS=https://app.example.com
const ALLOWED = new Set((process.env.ALLOWED_ORIGINS ?? "").split(",").filter(Boolean));

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    // Echo back the requesting origin — but only because it passed the allowlist
    res.setHeader("Access-Control-Allow-Origin", origin);
    // Let the browser include cookies/auth on those cross-origin requests
    res.setHeader("Access-Control-Allow-Credentials", "true");
    // Tell caches this response differs per origin, so one origin's
    // headers are never served to another
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {
    // Preflight response: which HTTP methods cross-origin callers may use
    res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE");
    // ...and which request headers they're allowed to send
    res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
    return res.status(204).end();
  }
  next();
});

Two things to get right with this pattern: set the Vary: Origin header so shared caches don't serve one origin's response to a different origin, and handle the OPTIONS preflight separately so the browser's preflight check gets a 204 without hitting your auth middleware.

What to avoid: Access-Control-Allow-Origin: * is fine for a fully public, unauthenticated API (a public CDN, an open data feed), but the moment an endpoint handles cookies or an Authorization header, * is not allowed with credentials and signals that you haven't thought about who should be calling this endpoint.

CSRF

Cross-Site Request Forgery exploits one browser behavior: the browser automatically attaches your cookies to a request to a site, no matter which site triggered it. So if you're logged into your bank and visit a malicious page, a hidden form there can POST to the bank, and the browser helpfully includes your session cookie. The bank sees a fully authenticated request you never meant to make.

The whole attack fits in a few lines of HTML:

<!-- On evil.com — the victim only has to load the page -->
<form action="https://yourbank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="5000" />
</form>
<script>document.forms[0].submit();</script>

Notice what this doesn't do. It never reads a response, so the same-origin policy is never violated. And a form-encoded POST is exactly the kind of request the browser sends without a preflight, so CORS never gets a say. The bank receives a well-formed, cookie-authenticated request that is indistinguishable from a real one. That's the core problem: the cookie proves the request came from the user's browser, but says nothing about whether the user meant to send it.

Note this is the mirror image of CORS: CORS is about reading a cross-origin response; CSRF doesn't need to read anything. The side effect (transfer money, change email) is the whole attack.

Defense 1: SameSite cookies

The SameSite cookie attribute tells the browser when a cookie may ride along on a cross-site request — it removes the ammunition the attack depends on. Lax (the default in modern browsers) withholds the cookie from cross-site subrequests: hidden forms, fetch calls, iframes, image tags. It still sends the cookie when the user deliberately navigates to your site by clicking a link, so people arrive logged in. Strict withholds the cookie even on those link clicks — users following a link from an email arrive logged out — which is the right trade for high-stakes actions like a banking session. None restores the old always-send behavior (and requires Secure); only use it for genuine cross-site embedding needs.

One subtlety: Lax still sends cookies on top-level GET navigations. That's why GET handlers must never change state — a state-changing GET is forgeable with nothing but a link, even with SameSite=Lax in place.

Defense 2: CSRF tokens

A CSRF token is a random per-session value the server embeds in each form (or exposes for an AJAX header) and verifies on every state-changing request. The attacker's page can trigger a request to your site, but it can't read your pages — the same-origin policy at work — so it can never learn the token, and its forged request fails validation. Most frameworks ship this: Django, Rails, and Laravel enable it by default; Express has middleware like csrf-csrf.

Tokens carry one assumption worth stating: they only hold while your origin is free of XSS. Injected script runs same-origin, so it can read the token off the page and forge a perfectly valid request. CSRF defenses assume the XSS battle is already won — another reason the escaping discipline from the Untrusted Input lesson isn't optional.

Both defenses meet in the cookie itself:

// A session cookie that resists both theft and forgery:
res.cookie("session", sessionId, {
  httpOnly: true,   // JavaScript can't read it → limits XSS theft
  secure: true,     // sent only over HTTPS
  sameSite: "lax",  // not sent on cross-site subrequests (forms, AJAX) → helps block CSRF
});

Those cookie flags tie this guide to the IAM guide's "Storing Tokens in the Browser" topic. The same flags decide whether a stolen-or-forged session is even possible.

Check your understanding

Question 1 of 2

"CORS only allows our own frontend": why isn't that auth?

A developer says your API doesn't need authentication checks because CORS only allows your own frontend origin. Why is this reasoning wrong?