CSRF Protection
Here is the attack. Someone signs in to your site, so their browser now holds
a session cookie for it. Later they visit a page somewhere else entirely, and
that page quietly submits a form to your application — POST /account/email,
say. The browser attaches your cookie, because browsers attach cookies by
origin regardless of who asked, and your server sees a perfectly valid request
from a signed-in user who never intended to make it.
That is cross-site request forgery, and the defence is to require something on every state-changing request that the other site cannot know: a random token your own pages include and an attacker’s cannot read.
Lemmego does this with middleware.VerifyCSRF, and a scaffolded project
already has it installed. The usual way you meet it is a 419 when a form is
submitted without a valid token.
How it works
- The first request in a session generates a random token with
crypto/randand stores it in the session under_token. Later requests reuse it — the token lives as long as the session does. - Every response that passes verification mirrors that token into an
XSRF-TOKENcookie (skipping/static), and exposes it on the request context as_token. - Reads — GET, HEAD and OPTIONS — are not verified.
- Every other method is verified: the middleware compares the token the request
carried against the one in the session, using
subtle.ConstantTimeCompare. - A mismatch returns
c.PageExpired(), which is HTTP 419.
The token is not rotated per request
A CSRF token is a shared secret for the session, not a one-time nonce, and it is left in place after a successful write.
Rotating on every verified request rejects any client still holding the previous
token — one whose request was already in flight, one that missed a Set-Cookie,
a second tab, or a page restored from the back/forward cache. Each of those gets
a 419, and stays broken until a full page load. Laravel, Rails and Django all
keep a per-session token for the same reason.
The token does change when the session itself is regenerated.
CSRF Token Sources
The middleware checks for the token in this order, stopping at the first hit:
- The
X-XSRF-TOKENheader - A
_tokenPOST form field - A
_tokenform value, which also covers the query string - A
_tokenfield in a JSON body
Configuration
middleware.VerifyCSRF(&middleware.CSRFOpts{
ExcludePatterns: []string{"/api/.*", "/webhook"},
})Excluded patterns are evaluated as regular expressions. Requests matching any pattern skip CSRF verification entirely.
In Templates
Go Templates
res.Template.Render supplies _token from the request context, the same way
it supplies errors, so the handler does not have to pass it through:
func TaskCreate(c app.Context) error {
return c.Render(res.NewTemplate(c, "tasks.page.gohtml"))
}<form method="POST" action="/tasks">
<input type="hidden" name="_token" value="{{ ._token }}">
</form>An explicit _token in WithData still wins, if you need to override it.
Templ
Generated projects ship a csrf component in templates/csrf.templ that reads
the token off the context:
templ csrf() {
if val, ok := ctx.Value("_token").(string); ok {
<input type="hidden" name="_token" value={ val }/>
}
}Usage:
<form method="POST" action="/tasks">
@csrf()
</form>Inertia
CSRF tokens are automatically included in Inertia requests through the XSRF-TOKEN cookie.
API Routes
For JSON API routes, exclude CSRF verification:
middleware.VerifyCSRF(&middleware.CSRFOpts{
ExcludePatterns: []string{"/api/.*"},
})API clients should use token-based authentication (JWT or API keys) instead.
Security Notes
- Tokens are generated using
crypto/rand - Token comparison uses
subtle.ConstantTimeCompare, so it leaks nothing through timing - The cookie carries the same value as the session token — that is the
double-submit pattern. It is not
HttpOnly, because the point is for JavaScript to read it and send it back in theX-XSRF-TOKENheader. The protection comes from the same-origin policy: a cross-site attacker can cause the cookie to be sent, but cannot read it to populate the header - The cookie is
SameSite=Lax, andSecurein production - Excluded paths skip verification entirely, so exclude only routes that authenticate some other way