Go API CSRF Protection: Modern Patterns
Master robust CSRF protection in Go microservices. This guide covers double-submit cookies, SameSite strategies, and edge deployment patterns for secure, high-performance APIs.
I saw “A modern approach to preventing CSRF in Go” hit Hacker News this morning, and it reminded me of a painful production incident from two years ago. A client’s Go-based API gateway was compromised through a Cross-Site Request Forgery (CSRF) attack that bypassed their authentication layer entirely. The root cause? A fundamental misunderstanding of how CSRF protection works in modern, stateless API architectures.
As someone who’s architected dozens of Go-based microservices and API gateways running on edge networks, I’ve learned that CSRF protection isn’t just about slapping a token validator into your middleware stack. It’s about understanding the threat model, choosing the right strategy for your API architecture, and implementing defense-in-depth that doesn’t break your performance budget. This guide will walk you through battle-tested patterns for robust CSRF prevention in Go APIs.
The CSRF Problem in Modern Go API Architectures
Cross-Site Request Forgery attacks exploit the browser’s automatic cookie transmission. When you’re authenticated to api.example.com and visit evil.com, that malicious site can make requests to your API with your cookies attached. If your Go API trusts those cookies alone as proof of identity, you’re vulnerable to a CSRF attack.
Traditional CSRF protection often relied on server-side session state and synchronizer tokens. However, modern cloud-native Go API architectures introduce different constraints for security and performance:
- Stateless APIs: No server-side sessions to validate against for CSRF tokens.
- Edge Deployment: Requests hit distributed edge nodes, not centralized servers, complicating token management.
- Microservices: Multiple Go services need coordinated CSRF protection without introducing tight coupling.
- Mobile + Web Clients: Different security models for various client types (e.g., browser-based vs. native mobile apps).
- High Throughput: CSRF token validation can’t add significant latency to your Go API endpoints.
I’ve seen teams implement CSRF protection that works perfectly in development but falls apart at scale or creates UX nightmares in production environments. Let’s explore robust solutions for your Go APIs.
Strategy 1: Double-Submit Cookie Pattern for Go APIs
This is my go-to approach for stateless Go APIs. The double-submit cookie pattern is elegant and highly effective for CSRF prevention:
- Generate a random CSRF token on the server.
- Send it to the client both as an
HttpOnly: falsecookie AND in the response body (e.g., JSON payload, meta tag). - Client stores the body token (e.g., localStorage, memory).
- Client sends the token in a custom HTTP header on subsequent state-changing requests (e.g.,
X-CSRF-Token). - Go Server validates that the cookie token matches the header token. If they match, the request is legitimate.
Here’s a production-ready Go implementation for a CSRF middleware:
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"time"
"crypto/subtle" // For constant-time comparison
)
type CSRFConfig struct {
TokenLength int
CookieName string
HeaderName string
CookiePath string
CookieDomain string
CookieSecure bool
CookieSameSite http.SameSite
ExemptMethods map[string]bool
}
func NewCSRFConfig() *CSRFConfig {
return &CSRFConfig{
TokenLength: 32,
CookieName: "csrf_token",
HeaderName: "X-CSRF-Token",
CookiePath: "/",
CookieSecure: true,
CookieSameSite: http.SameSiteStrictMode,
ExemptMethods: map[string]bool{
"GET": true,
"HEAD": true,
"OPTIONS": true,
},
}
}
func (c *CSRFConfig) generateToken() (string, error) {
bytes := make([]byte, c.TokenLength)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
// Middleware returns an http.Handler that provides CSRF protection.
func (c *CSRFConfig) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip CSRF check for safe methods (GET, HEAD, OPTIONS)
if c.ExemptMethods[r.Method] {
// Ensure a CSRF token cookie is set for future requests
if _, err := r.Cookie(c.CookieName); err != nil {
token, err := c.generateToken()
if err != nil {
http.Error(w, "Failed to generate CSRF token",
http.StatusInternalServerError)
return
}
c.setTokenCookie(w, token)
}
next.ServeHTTP(w, r)
return
}
// Validate CSRF token for unsafe methods (POST, PUT, DELETE, etc.)
cookieToken, err := r.Cookie(c.CookieName)
if err != nil {
http.Error(w, "Missing CSRF cookie. Please refresh and try again.", http.StatusForbidden)
return
}
headerToken := r.Header.Get(c.HeaderName)
if headerToken == "" {
http.Error(w, "Missing CSRF header. Please ensure your client sends X-CSRF-Token.", http.StatusForbidden)
return
}
// Constant-time comparison to prevent timing attacks
if !secureCompare(cookieToken.Value, headerToken) {
http.Error(w, "Invalid CSRF token. Request blocked.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (c *CSRFConfig) setTokenCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: c.CookieName,
Value: token,
Path: c.CookiePath,
Domain: c.CookieDomain,
MaxAge: int(24 * time.Hour.Seconds()), // Token valid for 24 hours
Secure: c.CookieSecure,
HttpOnly: false, // Must be readable by JavaScript for client-side inclusion in header
SameSite: c.CookieSameSite,
})
}
// secureCompare performs a constant-time comparison of two strings to prevent timing attacks.
func secureCompare(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
Key implementation details for Go developers:
HttpOnly: false: The CSRF cookie must be readable by JavaScript so the client can retrieve its value and send it in a custom header. This is not a security issue since it’s not an authentication token.- Constant-time comparison: Using
crypto/subtle.ConstantTimeCompareprevents timing attacks that could potentially leak token information by observing response times. - Cryptographically secure random:
crypto/randis used for token generation, ensuring high entropy and unpredictability. SameSite: Strict: An additional, powerful layer of defense for the CSRF token cookie itself (more on this below).
Strategy 2: SameSite Cookies for Enhanced CSRF Protection
The SameSite cookie attribute is a game-changer for web security and significantly strengthens CSRF protection in modern browsers. I’ve been using it in production for three years, and it has eliminated entire classes of CSRF attacks. It instructs browsers on when to send cookies with cross-site requests.
Here’s how you might set a session cookie in Go with SameSite enabled:
func setAuthCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
MaxAge: int(7 * 24 * time.Hour.Seconds()), // 7 days expiration
Secure: true, // Always use Secure for session cookies
HttpOnly: true, // Important for session cookies to prevent XSS access
SameSite: http.SameSiteLaxMode, // Or StrictMode, depending on requirements
})
}
Understanding SameSite modes:
Strict: The cookie is never sent on cross-site requests. This offers the strongest CSRF protection but can break legitimate user flows like OAuth redirects or clicking links from external sites.Lax: The cookie is sent on top-level navigation (GET requests only) but not on embedded requests (e.g.,<img>,<iframe>, POST forms). This provides strong CSRF protection for state-changing operations while maintaining better compatibility.None: The cookie is always sent, including on cross-site requests. This requires theSecureflag to be set and is typically used for third-party embeds that need cookies. It offers no CSRF protection.
My production recommendation for Go APIs: Use SameSite=Lax for your primary session cookies and SameSite=Strict for your CSRF token cookie (as shown in Strategy 1).
Here’s why: SameSite=Lax prevents CSRF on state-changing operations (POST, PUT, DELETE) while allowing legitimate cross-site GET requests (e.g., user clicks a link from an email). This works for 90% of applications without breaking user experience. Combining it with the double-submit pattern provides robust, layered security.
Strategy 3: Origin/Referer Header Validation for Non-Cookie APIs
For Go APIs that don’t rely on cookies for authentication (e.g., using JWTs in Authorization headers, API keys), you still need CSRF protection against “login CSRF” or other attacks where a malicious site might trick a user into performing an action. Origin header validation is your friend here.
This strategy involves checking the Origin and/or Referer HTTP headers to ensure the request originates from an allowed domain.
package middleware
import (
"net/http"
"net/url"
"strings"
)
// AllowedOriginsMiddleware returns an http.Handler that validates the Origin/Referer headers.
func AllowedOriginsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
allowedMap := make(map[string]bool)
for _, origin := range allowedOrigins {
allowedMap[strings.ToLower(origin)] = true // Normalize to lower case
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Origin header is typically present on cross-origin requests,
// and on same-origin POST/PUT/DELETE requests.
// It's usually absent on same-origin GET/HEAD requests.
if origin == "" {
// Fall back to Referer header if Origin is missing.
// Referer can be spoofed or removed by privacy settings,
// so it's less reliable than Origin. Use as a secondary check.
referer := r.Header.Get("Referer")
if referer != "" {
origin = extractOrigin(referer)
}
}
// If an origin is present, validate it against our allowlist.
// If no origin is found (e.g., direct access, some privacy settings),
// we might allow it or implement stricter rules.
if origin != "" {
if !allowedMap[strings.ToLower(origin)] {
http.Error(w, "Invalid origin: Request blocked", http.StatusForbidden)
return
}
}
// If no origin was found and allowedMap doesn't contain "",
// you might want to block here too for stricter security.
// For now, if no origin, we proceed.
next.ServeHTTP(w, r)
})
}
}
// extractOrigin parses a URL string and returns its scheme + host (e.g., "https://example.com").
func extractOrigin(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
// Return scheme://host (e.g., https://www.example.com)
return u.Scheme + "://" + u.Host
}
Production gotcha for Origin and Referer in Go APIs: The Origin header isn’t always present. Browsers typically send it on cross-origin requests and same-origin POST/PUT/DELETE requests. It’s often omitted for same-origin GET requests. Your validation logic needs to handle missing headers gracefully, potentially falling back to Referer (which is less reliable) or defining a default behavior for requests without an Origin. Always normalize origins (e.g., to lowercase) for consistent validation.
Edge Deployment CSRF Considerations for Go APIs
When deploying Go APIs to edge networks (Cloudflare Workers, Fastly Compute@Edge, AWS Lambda@Edge), CSRF protection presents unique challenges. Statelessness and distributed environments require careful design.
Challenge 1: Cookie Encryption at the Edge
Edge workers often can’t access your backend encryption keys or shared secrets needed to decrypt session cookies or traditional CSRF tokens.
Solution: Use Signed Tokens Instead. Instead of encrypting CSRF tokens, sign them with a shared secret. This allows edge nodes to validate the token’s integrity and authenticity without needing to decrypt state.
package csrf
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"time"
"crypto/subtle"
"math/rand" // Using math/rand for payload, crypto/rand for secret
)
// TokenService manages the generation and validation of signed CSRF tokens.
type TokenService struct {
secretKey []byte
}
// NewTokenService creates a new TokenService with a given secret.
// The secret should be a strong, randomly generated key.
func NewTokenService(secret string) *TokenService {
return &TokenService{
secretKey: []byte(secret),
}
}
// Generate creates a new signed CSRF token.
func (s *TokenService) Generate() (string, error) {
timestamp := time.Now().Unix()
payload := generateRandomString(32) // Random payload for uniqueness
dataToSign := fmt.Sprintf("%s:%d", payload, timestamp)
signature := s.sign(dataToSign)
rawToken := fmt.Sprintf("%s:%d:%s", payload, timestamp, signature)
return base64.URLEncoding.EncodeToString([]byte(rawToken)), nil
}
// Validate checks if a given token is valid, not expired, and has a correct signature.
func (s *TokenService) Validate(token string, maxAge time.Duration) bool {
decoded, err := base64.URLEncoding.DecodeString(token)
if err != nil {
return false
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 3 {
return false // Malformed token
}
payload, timestampStr, signature := parts[0], parts[1], parts[2]
// Reconstruct data used for signing
dataToSign := fmt.Sprintf("%s:%s", payload, timestampStr)
expectedSig := s.sign(dataToSign)
// Verify signature in constant time
if subtle.ConstantTimeCompare([]byte(signature), []byte(expectedSig)) != 1 {
return false // Signature mismatch
}
// Check expiration
ts, err := parseTimestamp(timestampStr)
if err != nil {
return false // Invalid timestamp
}
if time.Since(ts) > maxAge {
return false // Token expired
}
return true // Token is valid
}
// sign generates an HMAC-SHA256 signature for the given data.
func (s *TokenService) sign(data string) string {
h := hmac.New(sha256.New, s.secretKey)
h.Write([]byte(data))
return base64.URLEncoding.EncodeToString(h.Sum(nil))
}
// generateRandomString creates a random alphanumeric string.
func generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567