CORS vs CSRF
Published
These two get mixed up because both involve one website talking to another, and both show up as errors in the browser console. They are opposites in a useful way. CORS is a permission system: your server tells the browser which other sites may read its responses. CSRF is an attack: another site makes your browser send a request to your server, with your cookies attached, and the server cannot tell you did not mean it.
Reading versus sending. Keep those two words and the rest follows.
Your bank's API has no CORS headers at all, so the browser blocks every other site from reading its responses. Is the bank protected from an evil site that submits a hidden form to /transfer?
The rule underneath both: same origin#
An origin is scheme, host and port: https://app.acme.in:443. Change any one and it is a
different origin. The browser's same origin policy says a page's scripts may freely talk
to their own origin, but may not read responses from another origin.
Notice the word read. The same origin policy was never about sending. Since the 1990s, any
page has been able to send requests anywhere: an <img> loads from another site, a <form>
posts to another site. Blocking that would break the web. And when the browser sends a
request to a site, it attaches that site's cookies. That combination is where CSRF comes
from.
CORS: opening the read door, carefully#
Your frontend at app.acme.in calls your API at api.acme.in. Different origins, so the
browser blocks the script from reading the response. Cross Origin Resource Sharing lets
the API say "I allow this":
Access-Control-Allow-Origin: https://app.acme.in
Access-Control-Allow-Credentials: true
Vary: OriginFor requests that could not have come from a plain HTML form (a JSON body, a PUT, a custom
header like Authorization), the browser first sends a preflight OPTIONS request asking
permission, and only sends the real request if the answer allows it.
Two rules that come up in every code review. Access-Control-Allow-Origin: * cannot be used
with credentials; browsers refuse it. And reflecting whatever Origin the request
carries back into that header, with credentials allowed, is the same as allowing every site
on the internet to read your users' data. It is one of the most common API misconfigurations
security scanners find.
CSRF: the request you did not mean to send#
You are logged in to your bank in one tab. In another, you open a funny meme page. It contains:
<form action="https://bank.example/transfer" method="POST" id="f">
<input name="to" value="attacker-account" />
<input name="amount" value="50000" />
</form>
<script>document.getElementById('f').submit()</script>Your browser sends that POST to the bank and attaches your bank session cookie, because it is a request to the bank. The bank sees a valid session and moves the money. The attacker never saw your cookie and never needed to read the response. That is cross site request forgery. CORS is irrelevant here: a form POST is a "simple" request with no preflight, and the damage is done by the time anyone thinks about reading the reply.
Play with the three defences and see what each one actually stops.
Defending against CSRF#
SameSite cookies. The SameSite attribute tells the browser when to attach a cookie to a
request started by another site. Strict: never. Lax: only on top level navigations with
safe methods, like clicking a link (so a cross site form POST goes without it). None: always,
and it must also be Secure. Chrome treats cookies with no SameSite attribute as Lax by
default since 2020, which quietly killed a lot of classic CSRF. Set it explicitly anyway, since
not every browser applies that default.
CSRF tokens. The server puts a random value in each form or page, tied to the session, and requires it back on every state changing request. The attacker's page cannot read your bank's pages (the same origin policy), so it cannot know the token.
Check where the request came from. Modern browsers send Origin and Sec-Fetch-Site
headers that pages cannot forge. Rejecting state changing requests where
Sec-Fetch-Site is cross-site is a short, effective rule.
Do not use cookies for API auth. A token in an Authorization header is not attached
automatically, so there is nothing for another site to ride on. That swaps CSRF risk for XSS
risk, which is the subject of cookies vs localStorage.
// Reject state changing requests that another site started.
app.use((req, res, next) => {
const safe = ['GET', 'HEAD', 'OPTIONS'].includes(req.method)
const site = req.get('Sec-Fetch-Site') // same-origin | same-site | cross-site | none
if (!safe && site === 'cross-site') return res.status(403).send('cross site request blocked')
next()
})| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| CORS | Lets chosen origins read your API responses from the browser. | Does not stop requests from being sent, so it is not a CSRF defence. Loose configs leak data. | Whenever a frontend calls an API on another origin. |
| SameSite=Lax or Strict | Browser keeps the session cookie off cross site requests. One attribute, broad protection. | Strict breaks inbound links (you arrive logged out). Same site subdomains still count as same site. | Every session cookie. Lax is the usual choice. |
| CSRF tokens | Works regardless of browser cookie behaviour. | Every form and AJAX call must carry it. Undone by an XSS or a careless CORS policy. | Cookie authenticated apps, as defence in depth. |
| Origin / Sec-Fetch-Site checks | A few lines of middleware, no state. | Very old browsers do not send Sec-Fetch headers. | A cheap extra layer on every state changing endpoint. |
In the interview#
Checkpoint#
1. The bank sends no CORS headers. An evil page auto submits a form to /transfer. What happens if the session cookie is SameSite=None and there is no CSRF token?
2. Why can't an attacker's page just read the CSRF token from your site?
3. Which header combination should never appear together?
- CORS: who may read. CSRF: tricking the browser into sending.
- The same origin policy blocks reading, never sending.
- Preflight happens for non simple requests: JSON bodies, PUT and DELETE, custom headers.
- CSRF defences: SameSite, CSRF tokens, Origin and Sec-Fetch-Site checks.
- Never reflect Origin with credentials. Allowlist exact origins.
CORS and CSRF are about reading versus sending. The same origin policy stops a script from reading responses from another origin, but it never stopped sending: forms and images have always crossed origins, and the browser attaches the target site's cookies. CORS is how a server opts in to letting specific other origins read its responses, with a preflight OPTIONS request for anything a plain form could not send. CSRF is the attack where another site makes your browser send a state changing request with your cookies. CORS does not stop it, because the request executes before any response is read. The defences are SameSite cookies, Lax by default, CSRF tokens that other origins cannot read, checking Origin or Sec-Fetch-Site on state changing requests, and keeping GETs side effect free. And a loose CORS policy that reflects any origin with credentials undoes the token defence.