In the previous articles, we hardened the login: constant timing, progressive lockout, no existence leaks. The user is authenticated. Now the question is: how do you protect the actions they take once logged in?
CSRF. The topic everyone thinks they've mastered because they put a token in a hidden field in 2015. Except the double-submit cookie — the pattern found in 80% of implementations — has weaknesses most developers ignore.
Double-submit cookie: the pattern and its limits
Double-submit cookie works like this: the server sets a cookie
csrf_token=abc123, the form sends the same token in a hidden field,
the server compares both. If an attacker can't read the domain's cookies,
they can't forge the request.
The problem: the attacker doesn't need to read the cookie. They need to set it. And in several scenarios, that's possible:
- Compromised subdomain — an XSS on
blog.example.comcan set a cookie for.example.com - HTTP downgrade — if a single page on the domain is accessible via HTTP (even a redirect), a MITM attacker can inject a
Set-Cookie - Cookie tossing — some browsers accept cookies set by parent domains
If the attacker can set the CSRF cookie, they control both values (cookie and form field) and double-submit protects nothing.
For an authentication service handling sensitive operations, that's not acceptable. OWASP ASVS V4.2.2 explicitly recommends the synchronizer token pattern for high-risk contexts.
Synchronizer token: server-side storage
The synchronizer token is straightforward: the CSRF token is generated server-side, stored in the session (DB or Redis), and compared in constant-time on every mutative request.
// Token generation at session creation
func generateCSRFToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand failure = immediate stop
}
return base64.RawURLEncoding.EncodeToString(b)
}
// Store in session
session.CSRFToken = generateCSRFToken()
store.Save(session)
Comparison uses subtle.ConstantTimeCompare — not ==
— to avoid a timing oracle on the token:
func validateCSRF(session *Session, provided string) bool {
expected := []byte(session.CSRFToken)
got := []byte(provided)
return subtle.ConstantTimeCompare(expected, got) == 1
}
The advantage: the token is never in a cookie. The attacker can neither read it nor set it. The only way to obtain it is DOM access (and at that point, it's XSS, not CSRF).
Middleware wire-order
The order in which you chain your middlewares isn't cosmetic. For CSRF, the middleware must execute:
- After session-load — otherwise you don't have the token to compare
- Before refresh-user — otherwise a forged CSRF request could trigger a refresh with side-effects
// Correct order
mux.Use(sessionMiddleware) // 1. load session
mux.Use(csrfMiddleware) // 2. validate CSRF token
mux.Use(refreshUserMiddleware) // 3. refresh user data
mux.Use(handler) // 4. process request
I saw a codebase where CSRF middleware came after refresh-user. Result:
a forged CSRF request triggered a SELECT + UPDATE last_seen on the user
before being rejected. Not a vulnerability per se, but an unnecessary side-effect
and a signal that wire-order wasn't thought through.
X-CSRF-Token header for JS requests
HTML forms send the token in a hidden field. But fetch() or
XMLHttpRequest calls don't go through a form. For those,
the standard pattern: custom X-CSRF-Token header.
// Client side: read token from a meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(data),
});
// Server side: accept token from header OR form field
func extractCSRFToken(r *http.Request) string {
// Header takes priority (JS requests)
if token := r.Header.Get("X-CSRF-Token"); token != "" {
return token
}
// Fallback to form field (HTML forms)
return r.FormValue("csrf_token")
}
The custom header has a bonus: cross-origin requests can't add custom headers without a CORS preflight. That's an extra protection layer, though it shouldn't be the only one.
Token rotation
Two schools: one token per session, or one per request. My take: one per session is sufficient for the vast majority of cases. Per-request tokens add complexity (race conditions on parallel requests, back button invalidation) for marginal security gain.
The only reason to rotate the token: after a privilege change (login, role escalation). And at that point, you're regenerating the entire session anyway.
Conclusion
Double-submit cookie is a fine default for standard applications. But when you handle authentication, financial operations, or high-risk actions, the server-side synchronizer token is the right choice. The token is never in a cookie, comparison is constant-time, and middleware wire-order enforces discipline on the processing chain.
So far we've covered login, brute force, and CSRF. All on a service running mTLS. As it happens, mTLS has its own subtleties: how do you serve three different audiences on a single TLS port with distinct client auth levels? That's the subject of the next article.