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.
Missing SameSite Cookie Attribute
// 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.
Double-Submit Cookie Pattern
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.
SameSite Cookie Protection
// 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
Refererbypass, fixed in v1.7.3 by additionally enforcing same-origin. - CVE-2025-47909 - introduced by that same fix. A host added to
TrustedOriginsis accepted over both its HTTPS and its HTTP origin, because the comparison ignores the scheme, so a network attacker who can servehttp://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/httphas no built-in filter chain - middleware only applies to handlers it explicitly wraps. Adding a new route directly viahttp.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()andecho.Echo.Start()callhttp.ListenAndServeon the engine directly. Buildingprotection.Handler(r)and then starting the server withr.Run()compiles, starts clean, serves every route, and applies no CSRF check at all - the wrapped handler is discarded. Pass it tohttp.ListenAndServeor to ahttp.Server{Handler: ...}instead. - Treating a request that carries no
Sec-Fetch-SiteorOriginas trusted:CrossOriginProtectionallows 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
AddInsecureBypassPatternon 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 OIDCform_postcallback. - Trusting a bare hostname, or the wrong scheme, in the allowlist:
AddTrustedOrigintakes anOriginheader value,scheme://host[:port], and returns an error for anything else - a return value worth checking rather than discarding. Trustinghttps://partner.exampledoes not trusthttp://partner.example, and it should not: that distinction is exactly what CVE-2025-47909 got wrong ingorilla/csrf. - Checking that an
Origin/Refererheader exists without validating its value: Code likeif origin != "" { next(w, r) }accepts a forged request from any origin, since an attacker's page also sends anOriginheader - just not the one the app expects. The header must be compared against an allowlist, not merely checked for presence.
Additional Resources
- CWE-352: Cross-Site Request Forgery
- Double Submit Cookie Pattern
- Echo CSRF Middleware
- Go 1.25 net/http.CrossOriginProtection
- GO-2025-3955: CrossOriginProtection bypass patterns in Go 1.25.0
- GO-2025-3884: gorilla/csrf trusted-origin scheme confusion
- MDN: SameSite Cookies
- OWASP CSRF Prevention Cheat Sheet