Layered CSRF in Go: the new way and the old way, together

Part of my Krabber series, a Twitter clone in Go. The full source is on GitHub.

Intro

Cross-site request forgery is the attack where another site tricks your logged-in browser into making a state-changing request to Krabber, like posting or deleting, without you meaning to. It’s an old problem with well-known defenses, and Go recently grew a built-in one. Krabber uses that new defense and three older ones at the same time, and I want to explain why I didn’t just pick the newest and call it done.

I. Layer one: Go’s CrossOriginProtection

Go 1.25 added http.CrossOriginProtection, which rejects cross-site state-changing requests by reading the browser’s own Sec-Fetch-Site and Origin headers. Modern browsers send these, and they can’t be forged by the attacking page, so it’s a clean check with no tokens to manage:

var cop http.CrossOriginProtection
cop.AddTrustedOrigin("https://krabber.net")
// then wrap the router with cop.Handler(mux)

That AddTrustedOrigin matters. CloudFront forwards the viewer’s Host to my origin, so this works today, but it’s the kind of thing that quietly breaks if the front door changes, so I set it explicitly rather than relying on a default.

II. Layer two: the token I haven’t removed yet

Before Go had layer one, the standard approach was a CSRF token: a secret value put in every form that the server checks on submit. Krabber still has these, in every form including the htmx ones, using the nosurf package.

I could probably remove them now that layer one is in place. But “the new thing works on my laptop” isn’t the same as “the new thing works in production, through CloudFront, on every browser my krabs use.” So the plan is to keep the tokens through launch, prove layer one holds in the real path, and only then retire the tokens. New security code is exactly where you want a backup while you build trust in it.

III. Layers three and four: habits, not code

The other two layers aren’t libraries, they’re rules the whole app follows.

Every data-changing action is a POST. There are no state-changing GET requests anywhere, so there’s no “click this link to delete your account” shape for an attacker to abuse in an image tag or a prefetch. Even logout is a POST. This sounds obvious, but a single state-changing GET is all it takes to undo a lot of CSRF defense, so it’s a rule, not a preference.

And the session cookie is set to SameSite=Lax, which tells the browser not to send it along with most cross-site requests in the first place. It’s a passive layer, it costs nothing, and it catches a whole class of attempts before any of my code even runs.

IV. Why four

Any one of these would stop most CSRF. So why run all four? Because they fail in different ways. The header check depends on the browser sending Sec-Fetch-Site, which almost all do, but “almost” is doing work there. The token depends on my forms being wired up correctly. The POST-only rule depends on me never slipping. SameSite depends on the browser honoring it. Stack them, and an attacker has to defeat all four at once, which is a much taller order than beating any single one. Defense in depth isn’t paranoia here, it’s cheap, because three of these four cost me almost nothing to keep.

Conclusion

The temptation with a shiny new built-in like CrossOriginProtection is to rip out the old defenses the day you add it. I’d rather layer them: run the new check, keep the proven token until the new one has earned its retirement, make every mutation a POST, and let SameSite=Lax catch the rest. It’s the kind of boring, overlapping defense that quietly never makes the news, which is the goal. Thanks for reading, and may your forms only submit when you mean them to.

cd ../blog