The attack, in one form
The browser sends that request to your server with your user's cookies attached, because that is what cookies do. Your server sees a perfectly normal, authenticated request to change an email address — and the attacker never has to read the response. Changing the state is the attack.
The classic version of this took over accounts by changing the recovery email, then requesting a password reset.
<form action="https://shop.doors.test/account/email" method="POST">
<input name="email" value="attacker@evil.test">
</form>
<script>document.forms[0].submit()</script>
Why it works, and what does not fix it
- Cookies are attached by the browser, not by your page. The request does not need JavaScript, and CORS does not apply to a form post.
- Checking the referrer is weak: it can be absent for privacy reasons, so you must either fail open (useless) or break real users.
- Using POST is not a defence — the form above is a POST.
- A CORS policy does not help: CORS stops the attacker reading the response. The write already happened.
SameSite: the fix you get for free
SameSite=Lax means the cookie is not sent on a cross-site POST at all, which is precisely the attack above. A clicked link still works, so nothing breaks for real users.
It is the default in modern browsers, which is why CSRF has become much rarer. It is not a complete answer: cookies explicitly set to SameSite=None (an embedded widget, an SSO flow), older browsers, and GET endpoints that change state are all still exposed.
Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax; Path=/
The token, for the cases SameSite does not cover
The attacker's page cannot read the token: it is in your HTML, on your origin, and the same-origin policy stops them fetching it. Generate it per session (or per form), compare it in constant time, and require it on every state-changing method.
For an API called by JavaScript, the double-submit variant is common: the same random value in a cookie and in a header, compared on the server — no server-side storage needed, and the attacker can set neither.
// rendering the form
<input type="hidden" name="_csrf" value="{{ token }}">
// on the server
if (!req.body._csrf || !timingSafeEqual(req.body._csrf, session.csrfToken)) {
return res.status(403).send("bad csrf token");
}
And check the Origin header
const ALLOWED = ["https://shop.doors.test"];
if (!["GET", "HEAD", "OPTIONS"].includes(req.method)) {
const origin = req.headers.origin;
if (!origin || !ALLOWED.includes(origin)) return res.status(403).send("bad origin");
}
It is sent on every POST and every cross-origin request, it cannot be forged by a page, and it contains no path — so there is no privacy reason to strip it. Checking it is a cheap second layer, and unlike the referrer it is safe to fail closed on.
The rule underneath all of it
A GET must never change anything. If /account/delete works as a GET, then an <img src> on any page on the internet deletes accounts, and no token in the world is being checked. Safe methods, as the HTTP lesson defined them, are the foundation the rest of this lesson stands on.