CSRF: Why Double-Submit Cookie Falls Short for Financial-Grade Security

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 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.com can 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:

  1. After session-load — otherwise you don't have the token to compare
  2. 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.

Comments (0)