Csrf Tokens The Fix That Actually Works
The fix for CSRF was never the cookie. It's a value the attacker's forged request cannot possibly know, generated per session, checked on every state-changing request.
The last post left Meridian Bank's transfer endpoint exactly as exposed as it started. SameSite narrowed the gap, it never closed it, so the fix has to come from somewhere else.
Back to the Third Ingredient
Two posts back, the three things a CSRF attack needs came down to a worthwhile action, an auto-attached cookie, and no unpredictable parameter guarding the request.
The first two are basically unavoidable. A useful app has actions worth protecting, and cookies exist precisely so the browser doesn't make you log in on every request. The third one is the only lever left to pull, and it's the one that actually works.
What Makes a Token Work as a Defense
A CSRF token only does its job if it satisfies a short, strict list of properties.
- Unpredictable per session. Not hardcoded, not the same value reused for every user, not something derivable from anything public about the request.
- Tied to the session itself. The token dies the moment the session does, so a stale token from a logged-out session is worthless even if someone finds it later.
Here's the trap that looks tempting and isn't: storing the token only in a cookie. A cookie is exactly the thing the browser attaches automatically to every request, forged or not. If the "secret" value rides along on the same mechanism the attacker is already exploiting, it was never a secret at all.
The entire point of a CSRF token is a value the attacker's forged request cannot possibly know or have access to. A cookie fails that test by definition, because the whole reason CSRF works in the first place is that cookies travel automatically.
Building It for Meridian Bank
Fixing Meridian's transfer endpoint from the last post starts at login. The moment Peter authenticates, the server generates a token alongside his session.
const crypto = require("crypto");
const csrfToken = crypto.randomUUID();crypto.randomUUID() works fine here, and so does calling Node's crypto module directly for raw random bytes. The specific generation method matters far less than what happens with the result afterward. What matters is that it's random enough nobody can guess it and it never gets reused.
That token gets stored scoped to the session, not permanently, and not regenerated on every single request either. Both extremes create real problems.
- A permanent, hardcoded token is just a public constant with extra steps. Everyone gets the same one, and an attacker only has to find it once.
- Regenerating on every request sounds more secure, but it breaks ordinary use. Hit the back button, and the token on that stale page no longer matches the one the server expects. Open two tabs, and the same problem shows up again.
Scoped to the session is the middle ground that actually works. The token stays valid for as long as Peter is logged in, and dies the moment his session does.
The transfer page renders that token into a hidden field, sitting right alongside the real form fields.
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="a1b2c3d4-..." />
<input type="text" name="to" placeholder="Recipient" />
<input type="number" name="amount" placeholder="Amount" />
</form>Nothing about that field is visible in the rendered page, but it's still submitted with the rest of the form data. Only someone who actually loaded Meridian's real transfer page, as Peter, gets a copy of that value.
Validating It Server-Side
The transfer route checks the submitted token against the one stored for that session, and rejects anything that doesn't match.
app.post("/transfer", async (req, res) => {
const session = await getSession(req);
if (req.body._csrf !== session.csrfToken) {
return res.status(403).send("Unauthorized");
}
await transferFunds(session.userId, req.body.to, req.body.amount);
res.redirect("/dashboard");
});That response deliberately says nothing about what went wrong. Handing back a message like "CSRF token mismatch" or "token expired" would be handing an attacker a diagnostic tool. Every detail a server leaks about why a request failed is one less thing an attacker has to guess.
Send the meme-page attack from the last post at this endpoint now, and it fails outright. The cookie still arrives, exactly as before. There's just nothing sitting in the request body that matches, because the attacker never had access to Peter's real transfer page in the first place.
Don't Reach for csurf
If you go looking for prior art on this, you'll run into csurf, a once-popular Express middleware built for exactly this problem.
It's deprecated now, and for a real reason: the library itself turned out to have security holes, the same library-trust point already made about cookie-parser applies here in reverse. Popularity alone was never proof of safety. npm will actively warn you off installing it today.
That doesn't mean hand-rolling this from scratch is the only option left. Actively maintained alternatives exist for teams that want a battle-tested implementation instead of building their own, and checking whether a security-critical library is still maintained matters more than checking how popular it once was.
Two Layers, Two Different Failure Modes
Stack this token defense on top of the signed, HttpOnly session cookie from earlier in this course, and something useful happens: a mistake in one layer doesn't automatically compromise the other.
If a cookie attribute gets misconfigured somewhere, the token still catches the forged request. If a token implementation has a bug, the cookie's own protections, HttpOnly blocking script access, Secure blocking plain-text transport, are still doing their job. Neither layer has to be perfect on its own, because they're protecting against genuinely different failure modes.
What This Sets Up
Meridian's single-token setup closes the gap that started this chapter. There's a sharper version of this pattern worth knowing, and a genuinely dangerous edge case that a token alone doesn't fully cover.
The Essentials
- A CSRF token has to be unpredictable per session and tied to that session's lifetime. Hardcoded or reused tokens defeat the entire point.
- Storing the token only in a cookie doesn't work. Anything the browser auto-attaches is exactly what the attacker's forged request already has.
crypto.randomUUID()or Node'scryptomodule both generate a suitable token. The generation method matters less than what happens to the result afterward.- Scope the token to the session, not permanent and not regenerated per request. Both extremes create real problems: a public constant on one end, back-button and multi-tab breakage on the other.
- Reject a mismatched token silently, without leaking why. A specific error message is a diagnostic gift to an attacker.
csurfis deprecated because the library itself had security holes, a reminder that popularity was never proof of safety. A signed cookie and a CSRF token protect against different failure modes, so a mistake in one still leaves the other standing.
Next up: the double-token refinement and the one edge case tokens alone don't cover, where a state-changing GET endpoint slips right past all of this.
Further Reading and Watching
- How Cross-site Request Forgery (CSRF) Tokens Work: webpwnized's walkthrough of token generation, storage, and validation in practice.
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet: the canonical reference for synchronizer token implementation and its common pitfalls.
Keep reading