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.

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. What the request can do is bounded by the victim's own permissions: a transfer of their funds, a change to their email address, deletion of their account.

Since Go 1.25, net/http ships a CSRF control of its own - CrossOriginProtection - but it is opt-in, so an application that never wires it up is unprotected by default, and anything written before Go 1.25 either hand-rolled the defense or took a third-party package. The current defense checks where a request came from rather than proving the sender was shown a token. CrossOriginProtection rejects any unsafe method whose Sec-Fetch-Site header says the request came from anywhere but the site's own origin, falling back to comparing the Origin header's host against Host where that header is missing. It is a single wrapper around the handler tree, so it cannot be forgotten on one route the way a hidden form field can. Synchronizer tokens (unpredictable random values in forms) and double-submit cookies (comparing a token in a cookie against one in the request) are still correct, are what most existing Go code does, and still have narrow uses - but they are no longer the first thing to reach for. The SameSite cookie attribute helps at the browser level and is not sufficient alone: workflows may force Lax or None, and a subdomain an attacker controls counts as same-site, so the cookie is still sent.

Primary Defence: Wrap the whole handler tree in http.CrossOriginProtection (Go 1.25+; filippo.io/csrf is the same check as a module for older releases), so cross-site requests using an unsafe method are rejected before routing. Keep state changes off GET, HEAD and OPTIONS, which that check always allows. Set SameSite=Strict or SameSite=Lax on session cookies as defense-in-depth. Synchronizer tokens remain valid where they are already wired through the templates, and are worth adding on a high-value confirmation step, but new code does not need them for baseline CSRF protection.

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 handler accepts any POST carrying a valid session cookie, without checking where the request came from. When the victim's browser submits the attacker's form, it attaches that cookie automatically, so a form auto-submitted from the attacker's page is indistinguishable from the user clicking Transfer on the real site.

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 is not the same as having no protection on a current browser: browsers that support the attribute treat an unspecified value as Lax, though that default is more permissive than an explicit Lax - it still sends the cookie on a cross-site POST made within two minutes of the cookie being set. Browsers predating SameSite ignore it altogether. Neither case replaces the server-side check.

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: A client-generated token protects nothing, because the script on the attacker's forged form can run the same Math.random() line and produce a value this handler accepts. CSRF protection requires the server to generate an unpredictable token tied to the user's session, hold it server-side or in a separate cookie, and validate it on submission - so the value is one only the legitimate application can create and verify.

Secure Patterns

Cross-Origin Protection (Go 1.25+)

// SECURE - net/http cross-origin protection, applied to the whole tree
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /transfer", showTransferForm)
    mux.HandleFunc("POST /transfer", handleTransfer)
    mux.HandleFunc("POST /auth/callback", handleOIDCCallback)

    protection := http.NewCrossOriginProtection()

    // A second first-party origin that legitimately posts here. The value is
    // an Origin header - "scheme://host[:port]" - so a bare hostname is
    // rejected, and trusting the https:// origin does not also trust http://
    // on the same host.
    if err := protection.AddTrustedOrigin("https://admin.example.com"); err != nil {
        log.Fatalf("trusted origin: %v", err)
    }

    // The one route a browser is meant to post to cross-site: the identity
    // provider's form_post response. The bypass authenticates nobody - this
    // handler still has to validate the state parameter and the ID token.
    protection.AddInsecureBypassPattern("POST /auth/callback")

    // Rejections are a bare 403 by default. Log them: a spike is either an
    // attack or a first-party origin nobody added to the trusted list.
    protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("cross-origin rejected: %s %s origin=%q",
            r.Method, r.URL.Path, r.Header.Get("Origin"))
        http.Error(w, "cross-origin request forbidden", http.StatusForbidden)
    }))

    // SECURE - one wrapper covers every route, including ones added later.
    log.Fatal(http.ListenAndServe(":8080", protection.Handler(mux)))
}

func showTransferForm(w http.ResponseWriter, r *http.Request) {
    // SECURE - no hidden token to embed here, and so none to leave out
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprint(w, `<form method="POST" action="/transfer">
  <input name="to_account">
  <input name="amount">
  <button>Transfer</button>
</form>`)
}

func handleTransfer(w http.ResponseWriter, r *http.Request) {
    // SECURE - a cross-site POST was refused with 403 before reaching here
    fmt.Fprintf(w, "transferred %s to %s",
        r.FormValue("amount"), r.FormValue("to_account"))
}

func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
    // Exempted above, so this handler carries the whole burden: compare the
    // state parameter against the value stored at redirect time, then verify
    // the ID token before establishing a session.
    http.Redirect(w, r, "/", http.StatusSeeOther)
}

Why this works: Every browser since 2023 sends Sec-Fetch-Site on every request, and a page cannot set or suppress it - it is added by the browser and describes the relationship between the page that initiated the request and the target. CrossOriginProtection reads it, and rejects any request using a method other than GET, HEAD or OPTIONS whose value is anything but same-origin or none. Where the header is absent it compares the Origin header's host against Host instead. The attacker's form on evil.com therefore arrives labelled cross-site and is refused with 403 before it reaches a route. Note that same-site is rejected as well, which is the point where this parts company with the SameSite cookie attribute: a sibling subdomain an attacker controls is same-site, so SameSite=Strict would still send the cookie, but this check refuses the request. Because the check is a Handler wrapping the mux rather than something each form has to carry, there is nothing per-endpoint to forget - which removes the failure mode that token schemes keep producing, where the middleware is configured correctly and one new endpoint has no token in its form.

What it does not do. Requests carrying neither Sec-Fetch-Site nor Origin are allowed through. That is deliberate rather than a gap: CSRF is an attack that spends a browser's ambient credentials, and a browser capable of being used that way always sends one of those headers. A curl request, a mobile client or a server-to-server webhook sends neither, is not a CSRF vector, and needs no bypass pattern - but it also means this is a CSRF control and not authentication or a general request filter. Those callers still need their own credential check. Two further limits are worth stating. The Origin fallback runs only when Sec-Fetch-Site is absent, and it compares hosts rather than origins - Host carries no scheme - so a request from http://yourapp.com to https://yourapp.com passes it. Go fails open there on purpose, reasoning that a browser old enough to omit Sec-Fetch-Site has already made that trade-off; HSTS is the mitigation. And AddInsecureBypassPattern wants Go 1.25.1 or later - in 1.25.0 it also exempted requests that ServeMux would have redirected to the pattern, which exempted more than intended (CVE-2025-47910).

To verify the fix, replay a captured state-changing request three times: once unchanged, asserting the normal 2xx, and once with Sec-Fetch-Site: cross-site added and nothing else altered, asserting 403 and that the underlying record did not change. Send a third with Sec-Fetch-Site: same-site, which must also be refused - that is the case a SameSite=Strict cookie would have allowed, so it is the assertion that proves the check is doing something the cookie flag was not. Repeat with Sec-Fetch-Site removed and Origin: https://evil.example set, which exercises the fallback path. A test that only checks the 403 status can pass against a handler that rejected the response but still performed the write, so assert on the data as well.

Synchronizer Token Pattern

Tokens are no longer needed for baseline protection, but three cases still justify them: an existing codebase where they are already threaded through every template and are working; defense-in-depth where a single header check is not considered enough; and a high-value confirmation step, where a per-form token proves the user was actually served this form - something an origin check cannot tell you. Treat what follows as the pattern to maintain, not the one to add to a new service.

// SECURE - CSRF tokens with session storage
package main

import (
    "crypto/rand"
    "crypto/subtle"
    "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 subtle.ConstantTimeCompare([]byte(expectedToken), []byte(providedToken)) == 1
}

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.

The double-submit variant keeps no server-side token store: the token travels in a cookie as well as in the form, and the server checks the two halves against each other. That makes it the pattern reached for when there is nowhere to hang per-session state. It is the weaker of the two token schemes, and the reason is the whole design of the example below - matching halves prove only that whoever set the cookie also filled in the form, which is true of an attacker who can write a cookie for the domain from a sibling subdomain. The signature therefore has to be bound to the session, not just to the token.

// SECURE - Signed double-submit cookie, bound to the session
package main

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/base64"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"
)

// Load from the environment or a secrets manager - never a literal.
// A committed secret lets anyone mint valid CSRF tokens.
var csrfSecret = []byte(os.Getenv("CSRF_SECRET"))

// sign binds the random half of the token to the session it was issued for.
// The separator matters: without it, ("ab", "c") and ("a", "bc") produce the
// same MAC.
func sign(sessionID, token string) string {
    mac := hmac.New(sha256.New, csrfSecret)
    mac.Write([]byte(sessionID))
    mac.Write([]byte{0})
    mac.Write([]byte(token))
    return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

func issueCSRFToken(sessionID string) (string, error) {
    raw := make([]byte, 32)
    if _, err := io.ReadFull(rand.Reader, raw); err != nil {
        return "", err
    }
    token := base64.RawURLEncoding.EncodeToString(raw)
    return token + "." + sign(sessionID, token), nil
}

func validCSRFToken(sessionID, signedToken string) bool {
    token, providedMAC, ok := strings.Cut(signedToken, ".")
    if !ok {
        return false
    }
    return hmac.Equal([]byte(sign(sessionID, token)), []byte(providedMAC))
}

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

    signedToken, err := issueCSRFToken(session.ID)
    if err != nil {
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }

    // SECURE - the cookie half. HttpOnly is off because the page's own script
    // has to copy the value into the form. That is acceptable here only
    // because the token is useless without this session's cookie.
    http.SetCookie(w, &http.Cookie{
        Name:     "csrf_token",
        Value:    signedToken,
        Path:     "/",
        HttpOnly: false,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
    })

    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
    }

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

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

    if err := r.ParseForm(); err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    formToken := r.FormValue("csrf_token")
    if formToken == "" {
        formToken = r.Header.Get("X-CSRF-Token")
    }

    // SECURE - compare the halves as they were sent. Both carry the signature,
    // because the browser copied the cookie verbatim - comparing the cookie
    // against an unwrapped token would reject every legitimate request while
    // still looking like a working check.
    if subtle.ConstantTimeCompare([]byte(tokenCookie.Value), []byte(formToken)) != 1 {
        http.Error(w, "CSRF token mismatch", http.StatusForbidden)
        return
    }

    // SECURE - and confirm this server issued the token for THIS session.
    // Without this check the pattern is bypassable by anyone who can set a
    // cookie on the domain: they mint a token against their own session, set
    // it as both halves in the victim's browser, and the comparison above
    // passes.
    if !validCSRFToken(session.ID, formToken) {
        http.Error(w, "Invalid CSRF token", http.StatusForbidden)
        return
    }

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

Why this works: The token is set as a cookie and embedded in the form, and an attacker's page can trigger a request but cannot read the cookie to copy its value into the body - the same-origin policy stops that, so the two halves do not match. The HMAC does two jobs. It stops the token being tampered with, and because the session ID is inside the MAC, a token only validates against the session it was minted for. That second job is what closes cookie tossing: a sibling subdomain can write a csrf_token cookie on the parent domain and set both halves to a token it obtained legitimately from its own session, defeating a scheme that only compares the halves to each other. Note that the whole pattern rests on the attacker being unable to write cookies for the domain, which is a weaker assumption than the cross-origin check above relies on.

// 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 defense-in-depth
        })

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

Why this works: SameSite=Strict prevents the browser from sending cookies with cross-site requests, reducing CSRF exposure at the browser level. HttpOnly prevents JavaScript from reading the cookie, reducing XSS-based cookie theft. Secure ensures the cookie is only transmitted over HTTPS. SameSite alone is not a complete CSRF control: workflows may force Lax or None, a subdomain an attacker controls counts as same-site so the cookie is still sent, and state-changing endpoints still need a server-side check on where the request came from. Use SameSite as defense-in-depth alongside the cross-origin check.

AJAX Requests with Custom Headers

This is the hand-rolled ancestor of the cross-origin check above, and on Go 1.25 or later there is no reason to write it - http.CrossOriginProtection does the same job with less to get wrong. It is here because it is common in existing API code, and because the version most often found in the wild validates the Referer with a prefix match, which does not hold.

// SECURE - Custom header plus exact origin allowlist, for pre-1.25 codebases
package main

import (
    "net/http"
)

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

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 == http.MethodGet || r.Method == http.MethodHead ||
            r.Method == http.MethodOptions {
            next(w, r)
            return
        }

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

        // SECURE - exact match against the allowlist. Never a prefix test:
        // "https://yourapp.com" is a prefix of "https://yourapp.com.evil.com",
        // so a prefix check hands the attacker a domain that passes it.
        // Browsers send Origin on every non-GET/HEAD request, so an absent
        // value is a rejection rather than something to fall back from.
        if !allowedOrigins[r.Header.Get("Origin")] {
            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)
}

// 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: An attacker's page cannot add a custom header to a cross-origin request without a CORS preflight, and the application does not approve preflights from untrusted origins - so requiring X-CSRF-Protection rejects the simple cross-site form post outright. The Origin allowlist is the second half, and it has to be an exact string comparison against a fixed set: attacker-registered lookalikes such as https://yourapp.com.evil.com defeat a prefix or strings.HasPrefix test, and https://yourapp.com. (trailing dot) and http://yourapp.com are different origins that must not be accepted by accident. Do not pair this with a permissive CORS policy, which would let the preflight through and undo the first half.

Framework-Specific Guidance

Every Go router is an http.Handler, so the wrapper from the cross-origin pattern above applies unchanged: chi.Mux, gorilla/mux.Router and http.ServeMux need nothing beyond protection.Handler(r). The two frameworks below get their own sections only because each ships a convenience method that starts a server itself, and that method is exactly how the wrapper ends up being skipped.

Gin with Cross-Origin Protection

// SECURE - Gin behind net/http.CrossOriginProtection (Go 1.25+)
package main

import (
    "net/http"

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

func main() {
    r := gin.Default()
    r.GET("/form", showFormHandler)
    r.POST("/submit", submitFormHandler)

    // SECURE - rejects cross-origin unsafe requests before any route runs.
    protection := http.NewCrossOriginProtection()

    // Only where a genuinely separate origin must post here. Give the full
    // origin including the scheme - "https://admin.example.com", never a
    // bare host.
    // protection.AddTrustedOrigin("https://admin.example.com")

    // NOTE: r.Run(":8080") would start Gin directly and skip the wrapper.
    http.ListenAndServe(":8080", protection.Handler(r))
}

func showFormHandler(c *gin.Context) {
    // SECURE - no token to embed - the check is on the request's origin
    html := `
    <form method="POST" action="/submit">
        <input name="email" type="email" required>
        <button>Submit</button>
    </form>
    `

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

func submitFormHandler(c *gin.Context) {
    // SECURE - cross-origin POSTs were rejected with 403 before reaching here
    email := c.PostForm("email")

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

Why this works: CrossOriginProtection reads the Sec-Fetch-Site header that every browser has sent since 2023, and rejects any unsafe method (anything other than GET, HEAD or OPTIONS) whose request did not originate from the same origin. Where that header is absent it falls back to comparing Origin against Host. An attacker's page on evil.com cannot suppress or forge either header, so the forged POST is refused with 403 before Gin routes it. Because the check is on headers the browser controls, there is no token to generate, store, rotate, or accidentally leave out of one form - which removes the whole class of "the middleware was wired up but this one endpoint has no token" bug. Requests carrying neither header are allowed through, so this defends browser-driven CSRF and is not a substitute for authenticating non-browser clients.

On older Go, and on gorilla/csrf. net/http.CrossOriginProtection arrived in Go 1.25; before that, filippo.io/csrf provides the same check as a module. Do not reach for github.com/gorilla/csrf, which older guidance (including earlier versions of this page) recommended. Two advisories apply, and the second has no fix:

  • CVE-2025-24358 - a Referer bypass, fixed in v1.7.3 by additionally enforcing same-origin.
  • CVE-2025-47909 - introduced by that same fix. A host added to TrustedOrigins is accepted over both its HTTPS and its HTTP origin, because the comparison ignores the scheme, so a network attacker who can serve http:// for a trusted host gets a trusted origin. Go vulnerability report GO-2025-3884 records this as affecting all versions with no known fix; no version pin avoids it.

For a codebase already built on the gorilla API, filippo.io/csrf/gorilla is a drop-in replacement that keeps the same call shape.

Echo with Cross-Origin Protection

// SECURE - Echo behind net/http cross-origin protection
package main

import (
    "log"
    "net/http"

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

func main() {
    e := echo.New()
    e.GET("/form", echoFormHandler)
    e.POST("/submit", echoSubmitHandler)

    protection := http.NewCrossOriginProtection()

    // NOTE: e.Start(":8080") runs Echo's own server and never sees the
    // wrapper, so the application would start clean and be unprotected.
    log.Fatal(http.ListenAndServe(":8080", protection.Handler(e)))
}

func echoFormHandler(c echo.Context) error {
    // SECURE - no token to render into the form
    return c.HTML(http.StatusOK, `<form method="POST" action="/submit">
  <input name="username" required>
  <button>Submit</button>
</form>`)
}

func echoSubmitHandler(c echo.Context) error {
    // SECURE - a cross-site POST was refused with 403 before Echo routed it
    return c.JSON(http.StatusOK, map[string]string{
        "status":   "success",
        "username": c.FormValue("username"),
    })
}

Why this works: echo.Echo implements ServeHTTP, so it is an http.Handler like any other and the standard-library check wraps it whole - including routes registered by a group, a plugin, or someone else's pull request next month. Echo's own middleware.CSRFWithConfig remains available and is a reasonable choice where tokens are wanted; if you use it, it is a double-submit cookie scheme, so set CookieSecure: true, CookieSameSite: http.SameSiteStrictMode and a CookiePath, and read the double-submit section above for what that scheme does and does not assume. The two are not alternatives that cancel out - running the origin check as well costs nothing and covers the endpoints where somebody forgets the token.

Common Pitfalls

  • A route registered outside the wrapped router bypasses the middleware: net/http has no built-in filter chain - middleware only applies to handlers it explicitly wraps. Adding a new route directly via http.HandleFunc() on the default mux, instead of through the CSRF-wrapped router, silently skips validation for that one endpoint.
  • A parallel API/mobile endpoint added under a separate mux: e.g. a JSON variant of a form endpoint registered under its own http.NewServeMux() for versioning, authenticated by the same session cookie but never re-wrapped with the protection handler.
  • Starting the framework's own server, so the wrapper never runs: gin.Engine.Run() and echo.Echo.Start() call http.ListenAndServe on the engine directly. Building protection.Handler(r) and then starting the server with r.Run() compiles, starts clean, serves every route, and applies no CSRF check at all - the wrapped handler is discarded. Pass it to http.ListenAndServe or to a http.Server{Handler: ...} instead.
  • Treating a request that carries no Sec-Fetch-Site or Origin as trusted: CrossOriginProtection allows those through by design, because they are not browser-initiated and so cannot be CSRF. That is not a statement that the caller is authorised. An endpoint reachable by a non-browser client still needs its own credential check - and a webhook still needs its signature verified - which the cross-origin check will never do for it.
  • Adding a bypass pattern for a webhook that did not need one: server-to-server callers send neither header and are already allowed, so AddInsecureBypassPattern on a webhook route buys nothing and removes the protection if a browser ever reaches it. Reserve it for routes a browser genuinely posts to cross-site, such as an OIDC form_post callback.
  • Trusting a bare hostname, or the wrong scheme, in the allowlist: AddTrustedOrigin takes an Origin header value, scheme://host[:port], and returns an error for anything else - a return value worth checking rather than discarding. Trusting https://partner.example does not trust http://partner.example, and it should not: that distinction is exactly what CVE-2025-47909 got wrong in gorilla/csrf.
  • Checking that an Origin/Referer header exists without validating its value: Code like if origin != "" { next(w, r) } accepts a forged request from any origin, since an attacker's page also sends an Origin header - just not the one the app expects. The header must be compared against an allowlist, not merely checked for presence.

Additional Resources