Skip to content

CWE-352: Cross-Site Request Forgery (CSRF) - Go

Overview

Cross-Site Request Forgery (CSRF) vulnerabilities in Go web applications allow attackers to trick authenticated users into unknowingly executing unwanted actions. When a user is authenticated to a web application, their browser automatically includes authentication credentials (cookies, HTTP auth) with every request to that domain. Attackers exploit this by crafting malicious web pages or emails containing forms or scripts that trigger state-changing requests to the vulnerable application, executed with the victim's credentials.

Go's net/http package provides no built-in CSRF protection, making applications vulnerable by default. Common attack vectors include HTML forms on attacker-controlled sites posting to victim applications, malicious JavaScript making AJAX requests, or image tags with GET requests that trigger state changes. The impact ranges from unauthorized fund transfers and account modifications to privilege escalation and data deletion, depending on the victim's permissions.

Unlike some frameworks with automatic CSRF protection, Go developers must explicitly implement defenses. The SameSite cookie attribute provides partial protection in modern browsers but isn't sufficient alone due to browser compatibility and subdomain attacks. Comprehensive CSRF protection requires synchronizer tokens (random values in forms that attackers cannot predict), double-submit cookies (comparing token in cookie vs. request), or custom request headers that browsers won't send cross-origin.

Primary Defence: Use synchronizer tokens for all state-changing operations (POST, PUT, DELETE, PATCH). Generate cryptographically random tokens per session or request, embed them in forms and AJAX requests, and validate server-side. Set SameSite=Strict or SameSite=Lax on session cookies as defense-in-depth. Avoid state-changing GET requests entirely.

Common Vulnerable Patterns

No CSRF Protection on State-Changing Endpoints

// VULNERABLE - No CSRF protection
package main

import (
    "fmt"
    "net/http"
)

func transferMoneyHandler(w http.ResponseWriter, r *http.Request) {
    // Get authenticated user from session
    session := getSession(r) // Assumes session cookie authentication

    // Parse form data
    r.ParseForm()
    toAccount := r.FormValue("to_account")
    amount := r.FormValue("amount")

    // DANGEROUS: No CSRF token validation
    // Execute money transfer with user's credentials
    transferFunds(session.UserID, toAccount, amount)

    fmt.Fprintf(w, "Transfer complete: $%s to %s", amount, toAccount)
}

func main() {
    http.HandleFunc("/transfer", transferMoneyHandler)
    http.ListenAndServe(":8080", nil)
}

// ATTACK:
// Attacker hosts malicious page:
// <form action="https://victim-bank.com/transfer" method="POST">
//   <input name="to_account" value="attacker-account">
//   <input name="amount" value="10000">
// </form>
// <script>document.forms[0].submit();</script>
//
// When victim visits attacker's page while authenticated to bank,
// form auto-submits, transferring money to attacker

Why this is vulnerable: The application blindly accepts POST requests without verifying they originated from legitimate forms. When the victim's browser makes the request from the attacker's page, it automatically includes the session cookie, making the request appear legitimate. The server has no way to distinguish between requests initiated by the user clicking forms on the real application vs. auto-submitted forms on attacker sites.

State-Changing GET Requests

// VULNERABLE - State changes via GET
func deleteAccountHandler(w http.ResponseWriter, r *http.Request) {
    session := getSession(r)

    accountID := r.URL.Query().Get("account_id")

    // DANGEROUS: Deleting data via GET request
    // No CSRF protection needed - GET can be triggered via image tags!
    deleteAccount(session.UserID, accountID)

    fmt.Fprintf(w, "Account %s deleted", accountID)
}

// ATTACK:
// Attacker sends email with:
// <img src="https://victim-app.com/delete?account_id=12345">
// When victim opens email, image load triggers account deletion

Why this is vulnerable: GET requests were designed for safe, idempotent operations (reading data). Making state changes via GET is doubly dangerous: browsers send GET requests in many contexts (image loads, prefetching, browser history), and CSRF protections designed for POST forms don't apply. Any HTML element that loads a URL (<img>, <script>, <link>, <iframe>) can trigger the attack. Even CSRF tokens wouldn't help here because GET parameters are visible in URLs and browser history.

// VULNERABLE - Session cookie without SameSite
func loginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if authenticate(username, password) {
        sessionID := generateSessionID()

        // VULNERABLE: No SameSite attribute
        http.SetCookie(w, &http.Cookie{
            Name:     "session_id",
            Value:    sessionID,
            Path:     "/",
            HttpOnly: true,
            Secure:   true,
            // Missing: SameSite
        })

        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    }
}

// ATTACK:
// Without SameSite, cookies are sent with cross-site requests
// Attacker can execute CSRF attacks from their domain

Why this is vulnerable: Without the SameSite attribute, browsers send cookies with all requests to the domain, including cross-site requests from attacker pages. SameSite=Lax prevents cookies on cross-site POST requests (blocking most CSRF), while SameSite=Strict prevents them on all cross-site navigation. Omitting SameSite means full CSRF vulnerability in browsers that support it, and older browsers lack protection entirely.

Client-Side Generated CSRF Tokens

// VULNERABLE - Client generates CSRF token
func formPageHandler(w http.ResponseWriter, r *http.Request) {
    html := `
    <form method="POST" action="/update-email">
        <input name="email" type="email">
        <!-- DANGEROUS: Client-side generated token -->
        <input name="csrf_token" type="hidden" id="csrf">
        <button>Update</button>
    </form>
    <script>
        // VULNERABLE: JavaScript generates token
        document.getElementById('csrf').value = Math.random().toString(36);
    </script>
    `
    w.Write([]byte(html))
}

func updateEmailHandler(w http.ResponseWriter, r *http.Request) {
    r.ParseForm()
    token := r.FormValue("csrf_token")

    // INSUFFICIENT: No server-side validation
    // Attacker can just generate their own token
    if len(token) > 0 {
        updateEmail(r.FormValue("email"))
    }
}

Why this is vulnerable: Client-generated tokens provide no security because attackers can generate them too. The JavaScript on the attacker's forged form can create tokens identical to legitimate ones. CSRF protection requires server-side generation of unpredictable tokens tied to the user's session, stored server-side or in a separate cookie, and validated on submission. The token must be something only the legitimate application can create and verify.

Secure Patterns

Synchronizer Token Pattern

// SECURE - CSRF tokens with session storage
package main

import (
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "html/template"
    "io"
    "net/http"
    "sync"
)

var (
    csrfTokens = make(map[string]string) // sessionID -> csrfToken
    tokenMutex sync.RWMutex
)

func generateCSRFToken() (string, error) {
    bytes := make([]byte, 32)
    if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(bytes), nil
}

func getOrCreateCSRFToken(sessionID string) (string, error) {
    tokenMutex.RLock()
    token, exists := csrfTokens[sessionID]
    tokenMutex.RUnlock()

    if exists {
        return token, nil
    }

    // Generate new token
    newToken, err := generateCSRFToken()
    if err != nil {
        return "", err
    }

    tokenMutex.Lock()
    csrfTokens[sessionID] = newToken
    tokenMutex.Unlock()

    return newToken, nil
}

func validateCSRFToken(sessionID, providedToken string) bool {
    tokenMutex.RLock()
    expectedToken, exists := csrfTokens[sessionID]
    tokenMutex.RUnlock()

    if !exists {
        return false
    }

    // Constant-time comparison to prevent timing attacks
    return constantTimeCompare(expectedToken, providedToken)
}

func constantTimeCompare(a, b string) bool {
    if len(a) != len(b) {
        return false
    }

    result := 0
    for i := 0; i < len(a); i++ {
        result |= int(a[i] ^ b[i])
    }
    return result == 0
}

func transferFormHandler(w http.ResponseWriter, r *http.Request) {
    session := getSession(r)
    if session == nil {
        http.Error(w, "Not authenticated", http.StatusUnauthorized)
        return
    }

    // SECURE: Generate CSRF token for this session
    csrfToken, err := getOrCreateCSRFToken(session.ID)
    if err != nil {
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }

    // Render form with CSRF token
    tmpl := template.Must(template.New("form").Parse(`
    <form method="POST" action="/transfer">
        <input name="to_account" placeholder="Recipient account">
        <input name="amount" placeholder="Amount">
        <!-- SECURE: Server-generated CSRF token -->
        <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
        <button type="submit">Transfer</button>
    </form>
    `))

    tmpl.Execute(w, map[string]interface{}{
        "CSRFToken": csrfToken,
    })
}

func transferHandler(w http.ResponseWriter, r *http.Request) {
    // Only accept POST
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    session := getSession(r)
    if session == nil {
        http.Error(w, "Not authenticated", http.StatusUnauthorized)
        return
    }

    r.ParseForm()
    providedToken := r.FormValue("csrf_token")

    // SECURE: Validate CSRF token
    if !validateCSRFToken(session.ID, providedToken) {
        http.Error(w, "Invalid CSRF token", http.StatusForbidden)
        return
    }

    // Token valid - process transfer
    toAccount := r.FormValue("to_account")
    amount := r.FormValue("amount")

    transferFunds(session.UserID, toAccount, amount)

    fmt.Fprintf(w, "Transfer complete")
}

type Session struct {
    ID     string
    UserID string
}

func getSession(r *http.Request) *Session {
    // Stub - retrieve from cookie/database
    cookie, err := r.Cookie("session_id")
    if err != nil {
        return nil
    }
    return &Session{ID: cookie.Value, UserID: "user123"}
}

func transferFunds(userID, to, amount string) {}

func authenticate(user, pass string) bool { return true }

func generateSessionID() string { return "sess_123" }

Why this works: Server-generated CSRF tokens are cryptographically random and unpredictable - attackers cannot forge them. Tokens are tied to the user's session, stored server-side in memory or cache (Redis in production). When rendering forms, the token is embedded as a hidden field. On submission, the server validates that the provided token matches the stored token for that session. Attackers crafting forged forms cannot obtain the victim's token (same-origin policy prevents JavaScript from reading it from the legitimate site). Constant-time comparison prevents timing attacks that could leak token information.

// SECURE - Double-submit cookie CSRF protection
import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "io"
    "net/http"
)

var csrfSecret = []byte("your-secret-key-minimum-32-bytes-long")

func generateCSRFCookie() (string, error) {
    bytes := make([]byte, 32)
    if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
        return "", err
    }

    token := base64.URLEncoding.EncodeToString(bytes)

    // Sign token with HMAC
    mac := hmac.New(sha256.New, csrfSecret)
    mac.Write([]byte(token))
    signature := base64.URLEncoding.EncodeToString(mac.Sum(nil))

    return token + "." + signature, nil
}

func verifyCSRFToken(signedToken string) (string, bool) {
    // Split token and signature
    parts := split(signedToken, ".")
    if len(parts) != 2 {
        return "", false
    }

    token, providedSig := parts[0], parts[1]

    // Verify signature
    mac := hmac.New(sha256.New, csrfSecret)
    mac.Write([]byte(token))
    expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expectedSig), []byte(providedSig)) {
        return "", false
    }

    return token, true
}

func formWithDoubleSubmitHandler(w http.ResponseWriter, r *http.Request) {
    // Generate CSRF token and set as cookie
    csrfToken, err := generateCSRFCookie()
    if err != nil {
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }

    // SECURE: Set CSRF token in cookie
    http.SetCookie(w, &http.Cookie{
        Name:     "csrf_token",
        Value:    csrfToken,
        Path:     "/",
        HttpOnly: false, // JavaScript needs to read it
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
    })

    // Also send token to embed in form
    w.Header().Set("X-CSRF-Token", csrfToken)

    html := `
    <form method="POST" action="/process">
        <input name="data" value="test">
        <input type="hidden" name="csrf_token" id="csrf">
        <button>Submit</button>
    </form>
    <script>
        // Copy token from cookie to form field
        document.getElementById('csrf').value = 
            document.cookie.match(/csrf_token=([^;]+)/)[1];
    </script>
    `
    w.Write([]byte(html))
}

func processWithDoubleSubmitHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Get CSRF token from cookie
    tokenCookie, err := r.Cookie("csrf_token")
    if err != nil {
        http.Error(w, "Missing CSRF cookie", http.StatusForbidden)
        return
    }

    // Verify cookie signature
    cookieToken, valid := verifyCSRFToken(tokenCookie.Value)
    if !valid {
        http.Error(w, "Invalid CSRF cookie", http.StatusForbidden)
        return
    }

    // Get CSRF token from form/header
    r.ParseForm()
    formToken := r.FormValue("csrf_token")
    if formToken == "" {
        formToken = r.Header.Get("X-CSRF-Token")
    }

    // SECURE: Verify tokens match
    if cookieToken != formToken {
        http.Error(w, "CSRF token mismatch", http.StatusForbidden)
        return
    }

    // Process request
    fmt.Fprintf(w, "Request processed successfully")
}

func split(s, sep string) []string {
    // Simple string split implementation
    var parts []string
    start := 0
    for i := 0; i < len(s); i++ {
        if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep {
            parts = append(parts, s[start:i])
            start = i + len(sep)
            i += len(sep) - 1
        }
    }
    parts = append(parts, s[start:])
    return parts
}

Why this works: The CSRF token is set both as a cookie and embedded in the form/header. Attackers can trigger requests from their sites, but cannot read or set cookies for the victim domain due to same-origin policy. When the forged form submits, the browser sends the CSRF cookie automatically, but the attacker cannot include the token in the form body/header because they don't know its value. The server validates that both tokens match, ensuring the request came from a legitimate form that could read the cookie value. HMAC signature prevents token tampering.

// SECURE - SameSite cookies as defense-in-depth
func secureLoginHandler(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if authenticate(username, password) {
        sessionID := generateSessionID()

        // SECURE: Session cookie with comprehensive security
        http.SetCookie(w, &http.Cookie{
            Name:     "session_id",
            Value:    sessionID,
            Path:     "/",
            MaxAge:   3600,
            HttpOnly: true,  // Prevent JavaScript access
            Secure:   true,  // HTTPS only
            SameSite: http.SameSiteStrictMode, // CSRF protection
        })

        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    }
}

Why this works: SameSite=Strict prevents the browser from sending cookies with any cross-site requests, blocking CSRF attacks at the browser level. The cookie is only sent when the user directly navigates to the site or clicks links within the same site. HttpOnly prevents JavaScript from stealing the cookie via XSS. Secure ensures the cookie is only transmitted over HTTPS, preventing interception. However, SameSite alone isn't a complete solution - not all browsers support it, and SameSite=Lax (needed for some workflows) still allows GET request CSRF. Use SameSite as defense-in-depth alongside CSRF tokens.

AJAX Requests with Custom Headers

// SECURE - Custom header CSRF protection for AJAX
func apiCSRFMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Allow GET, HEAD, OPTIONS without CSRF check
        if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
            next(w, r)
            return
        }

        // SECURE: Require custom header for state-changing requests
        csrfHeader := r.Header.Get("X-CSRF-Protection")
        if csrfHeader != "1" {
            http.Error(w, "Missing CSRF header", http.StatusForbidden)
            return
        }

        // Additional validation: origin/referer check
        origin := r.Header.Get("Origin")
        referer := r.Header.Get("Referer")

        allowedOrigins := []string{"https://yourapp.com", "https://www.yourapp.com"}

        validOrigin := false
        for _, allowed := range allowedOrigins {
            if origin == allowed || startsWith(referer, allowed) {
                validOrigin = true
                break
            }
        }

        if !validOrigin {
            http.Error(w, "Invalid origin", http.StatusForbidden)
            return
        }

        next(w, r)
    }
}

func apiEndpointHandler(w http.ResponseWriter, r *http.Request) {
    // Process API request
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"status":"success"}`))
}

func main() {
    http.HandleFunc("/api/data", apiCSRFMiddleware(apiEndpointHandler))
    http.ListenAndServe(":8080", nil)
}

func startsWith(s, prefix string) bool {
    return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

// Client-side JavaScript:
// fetch('/api/data', {
//     method: 'POST',
//     headers: {
//         'Content-Type': 'application/json',
//         'X-CSRF-Protection': '1'  // Custom header
//     },
//     body: JSON.stringify({data: 'value'})
// })

Why this works: Browsers' same-origin policy prevents attackers from adding custom headers to cross-origin requests using simple CORS. An attacker's JavaScript on evil.com cannot make a POST request to yourapp.com with custom headers without a CORS preflight request, which the application won't approve. By requiring a custom header like X-CSRF-Protection, the server ensures requests come from JavaScript running on the legitimate domain. Origin/Referer validation provides additional defense by checking the request came from an allowed domain. This pattern is ideal for SPAs and APIs that don't use traditional forms.

Framework-Specific Guidance

Gin with CSRF Middleware

// SECURE - Gin with gorilla/csrf middleware
package main

import (
    "html/template"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/gorilla/csrf"
)

func main() {
    r := gin.Default()

    // SECURE: CSRF middleware
    csrfMiddleware := csrf.Protect(
        []byte("32-byte-long-auth-key-change-me!"),
        csrf.Secure(true),       // HTTPS only
        csrf.SameSite(csrf.SameSiteStrictMode),
        csrf.Path("/"),
    )

    r.Use(func(c *gin.Context) {
        // Wrap Gin with gorilla/csrf
        csrfMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            c.Request = r
            c.Next()
        })).ServeHTTP(c.Writer, c.Request)
    })

    r.GET("/form", showFormHandler)
    r.POST("/submit", submitFormHandler)

    r.Run(":8080")
}

func showFormHandler(c *gin.Context) {
    // SECURE: CSRF token automatically available
    token := csrf.Token(c.Request)

    html := `
    <form method="POST" action="/submit">
        <input name="email" type="email" required>
        <input type="hidden" name="gorilla.csrf.Token" value="` + token + `">
        <button>Submit</button>
    </form>
    `

    c.Header("Content-Type", "text/html")
    c.String(http.StatusOK, html)
}

func submitFormHandler(c *gin.Context) {
    // SECURE: Middleware automatically validates CSRF token
    email := c.PostForm("email")

    // Process form
    c.JSON(http.StatusOK, gin.H{
        "status": "success",
        "email":  email,
    })
}

Why this works: Gorilla's CSRF middleware automatically generates tokens, injects them into templates, and validates them on POST/PUT/DELETE/PATCH requests. The middleware uses the synchronizer token pattern with server-side storage (cookies). csrf.Secure(true) enforces HTTPS, and SameSite provides browser-level protection. Tokens are tied to the user's session, preventing reuse across users. The middleware handles all the complexity - developers just need to include csrf.Token() in forms or use csrf.TemplateField() for automatic injection.

Echo with CSRF Middleware

// SECURE - Echo with built-in CSRF middleware
package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := echo.New()

    // SECURE: Echo's built-in CSRF middleware
    e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
        TokenLength:  32,
        TokenLookup:  "form:csrf,header:X-CSRF-Token",
        CookieName:   "_csrf",
        CookiePath:   "/",
        CookieSecure: true,
        CookieHTTPOnly: true,
        CookieSameSite: http.SameSiteStrictMode,
    }))

    e.GET("/form", echoFormHandler)
    e.POST("/submit", echoSubmitHandler)

    e.Start(":8080")
}

func echoFormHandler(c echo.Context) error {
    // SECURE: Get CSRF token from context
    csrfToken := c.Get("csrf").(string)

    html := `
    <form method="POST" action="/submit">
        <input name="username" required>
        <input type="hidden" name="csrf" value="` + csrfToken + `">
        <button>Submit</button>
    </form>
    `

    return c.HTML(http.StatusOK, html)
}

func echoSubmitHandler(c echo.Context) error {
    // SECURE: Middleware automatically validates CSRF token
    username := c.FormValue("username")

    return c.JSON(http.StatusOK, map[string]string{
        "status":   "success",
        "username": username,
    })
}

Why this works: Echo's middleware automatically handles CSRF token generation, cookie management, and validation. TokenLookup specifies where to find the token (form field or header), supporting both traditional forms and AJAX. The middleware creates a signed cookie with the CSRF token and validates that submitted tokens match. CookieHTTPOnly: true prevents JavaScript from reading the cookie (tokens are passed via form fields or headers instead). SameSite=Strict adds browser-level protection. Failed validation automatically returns 403 Forbidden.

Additional Resources