Requests, files, and servers
Prevent cross-site request forgery
Protect cookie-authenticated state changes with intentional methods, SameSite cookies, origin checks, and proven anti-CSRF tokens.
The browser attaches cookies based on where a request is going, not on which page started it. CSRF, cross-site request forgery, abuses that. A hostile page triggers a request to your site, and the user’s session cookie rides along automatically.
Here is the attack in full. A signed-in user visits a page that contains this:
<form action="https://app.flaviocopes.com/account/email" method="post">
<input name="email" value="[email protected]">
</form>
<script>document.forms[0].submit()</script>
The form submits on load. The browser adds the session cookie because the request goes to app.flaviocopes.com. Your server sees a valid session and a normal POST, and changes the email address. The hostile page never reads the response. It doesn’t need to.
Layer the defenses
First, never change state on a GET. Then set SameSite on the session cookie so the browser leaves it off cross-site subrequests:
res.cookie('session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' })
With Lax, the auto-submitted POST above arrives without the cookie, and your server sees an anonymous request. That alone stops the basic attack in modern browsers.
Second, check where the request came from on the server. Browsers send a Sec-Fetch-Site header that says same-origin, same-site, cross-site, or none for a typed URL. Reject cross-site writes:
function sameSiteWrite(req, res, next) {
const site = req.headers['sec-fetch-site']
if (site && site !== 'same-origin' && site !== 'none') {
return res.status(403).end() // cross-site state change: refuse
}
next()
}
The Origin header works the same way if you’d rather compare it with your own host.
Third, add a per-session anti-CSRF token. The server puts a random value in the session and in every form. On each write it compares them:
if (req.body.csrf !== req.session.csrf) return res.status(403).end()
The hostile page can’t read your form, so it can’t learn the token, so it can’t build a valid request. Run the attack page again with all three layers in place and your log shows POST /account/email 403 and an unchanged email.
Where it still bites
SameSite=Lax is not a complete answer. Old clients ignore it, and some real flows are cross-site by design, like a payment provider that POSTs the user back to you. Test those flows before you rely on the cookie attribute, and keep the server-side check as the layer that always runs.
One more thing. XSS defeats all of this, because a script running on your origin can read the token and send same-origin requests. CSRF protection assumes you’ve already handled XSS. Do both.
Try this on your own project: build the attacker page above against your staging site and watch the email change. Then add the layers one at a time and capture the server’s decision after each. Finish by submitting the real form and confirming it still works.
Lesson completed