Go
net/http.CrossOriginProtection in Go 1.25, gorilla/csrf for token-based protection, and why forgetting to wrap the mux is the whole risk.
The rule
Use http.NewCrossOriginProtection() from the standard library, and wrap your mux with it.
Go 1.25 added CrossOriginProtection to net/http. It is the modern, header-based approach: it checks Sec-Fetch-Site and falls back to comparing Origin against Host when that header is absent. No tokens to mint, embed, store, or rotate, and no dependency to add.
That makes Go's story unusually clean in 2026 — the recommended defence is nine lines of setup and lives in the standard library. The corresponding risk is equally simple: nothing is on by default, so an application that never wraps its mux has no protection whatsoever, and there is no framework warning to tell you.
Use gorilla/csrf in addition when you need a token — for clients that may not send Fetch Metadata, or where you want the defence that survives a proxy rewriting Origin.
The standard library
package main
import "net/http"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /account/email", changeEmail)
mux.HandleFunc("POST /account/delete", deleteAccount)
// No protection at all. Go adds none by default and says nothing about
// it. changeEmail authenticates from a session cookie -- ambient -- so
// every route here is forgeable.
//
// This is the most common Go CSRF finding by a wide margin, and it is a
// finding of OMISSION: there is no wrong line to point at in review,
// only a missing one.
http.ListenAndServe(":8080", mux)
}
func changeEmail(w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, "session")
if err != nil || session.Values["userID"] == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
updateEmail(session.Values["userID"], r.FormValue("email"))
}Token-based protection
package main
import (
"net/http"
"os"
"github.com/gorilla/csrf"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /account", accountForm)
mux.HandleFunc("POST /account/email", changeEmail)
protect := csrf.Protect(
[]byte(os.Getenv("CSRF_AUTH_KEY")), // 32 bytes, from the environment
// Behind a reverse proxy, gorilla/csrf compares the request's Origin
// or Referer against r.Host -- which may be the internal hostname
// rather than the public one. Without TrustedOrigins this rejects
// legitimate traffic, and the usual "fix" found online is
// csrf.Secure(false), which is much worse.
csrf.TrustedOrigins([]string{"app.example.com"}),
csrf.Secure(true),
csrf.HttpOnly(true),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.Path("/"),
csrf.CookieName("__Host-csrf"), // forbids Domain; host-only
)
// Layer both: headers via the stdlib, tokens via gorilla.
cop := http.NewCrossOriginProtection()
http.ListenAndServe(":8080", cop.Handler(protect(mux)))
}Checklist
- The mux is wrapped —
cop.Handler(mux)orprotect(mux)— and the wrapped handler is what is actually passed toListenAndServe. Grep forListenAndServeand check each one. - Go version is at least 1.25 if using
CrossOriginProtection. AddInsecureBypassPatternandTrustedOriginslists are minimal and exact.- Behind a proxy:
csrf.TrustedOriginsis set rather thancsrf.Secure(false). - Session cookie sets
SameSiteexplicitly — the zero value emits no attribute. - Cookie is
__Host-prefixed,Secure,HttpOnly, with noDomain. - Routes register explicit methods. Go 1.22+ pattern syntax (
"POST /path") makes this easy; oldermux.Handle("/path", ...)matches every method, so the handler must checkr.Methoditself. - Handlers do not change state on
GET. CSRF_AUTH_KEYis 32 random bytes from the environment, not a literal in the source.- The session identifier is regenerated on login.
Related
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
Forcing the victim into the attacker's session, the OAuth state parameter, callback CSRF, and account-linking takeover.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.