Web application security in Go involves layered defenses. No single technique prevents all attacks — the goal is defense in depth: parameterized queries prevent SQL injection, html/template prevents XSS, CSRF tokens prevent cross-site request forgery, security headers defend against clickjacking and content sniffing, and input validation catches malformed requests before they reach business logic.
This guide covers the concrete implementation of each layer.
For authentication patterns see Go authentication and authorization. For cryptography see Go cryptography.
SQL Injection: Parameterized Queries Only
SQL injection is the most dangerous web vulnerability. The fix is absolute: never concatenate user input into SQL strings. Always use parameterized queries:
// ❌ SQL injection — user can pass "alice' OR '1'='1" to get all users
query := "SELECT * FROM users WHERE email = '" + userEmail + "'"
// ✅ Parameterized — the driver handles escaping at the protocol level
var user User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE email = $1",
userEmail,
).Scan(&user.ID, &user.Name, &user.Email)
Parameterization is not about escaping — it sends the query and the parameter as separate messages. The database never interprets the parameter as SQL syntax, regardless of its content.
Test your parameterization with actual injection payloads in your test suite — see Go security testing.
XSS: html/template Handles Escaping
Cross-site scripting (XSS) happens when user-controlled data is rendered as HTML without escaping. Go’s html/template package escapes output contextually — values in HTML text nodes are HTML-escaped, values in href attributes are URL-escaped, values in <script> blocks are JavaScript-escaped:
// ✅ html/template escapes automatically
import "html/template"
tmpl := template.Must(template.ParseFiles("index.html"))
tmpl.Execute(w, map[string]string{
"UserInput": `<script>alert('xss')</script>`,
})
// Template renders: <script>alert('xss')</script>
// ❌ text/template does NOT escape — XSS vulnerability
import "text/template" // never use for HTML output
template.HTML(s) marks a string as safe HTML, bypassing escaping. Only use it for content you’ve sanitized yourself — never for user input. For user-generated rich text (markdown editors), sanitize with github.com/microcosm-cc/bluemonday before marking safe.
Security Headers Middleware
Security headers instruct browsers on how to handle the response. Apply them globally via middleware:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Prevent clickjacking — page can't be embedded in iframes
h.Set("X-Frame-Options", "DENY")
// Prevent MIME type sniffing — browser uses declared Content-Type
h.Set("X-Content-Type-Options", "nosniff")
// Force HTTPS for 1 year (only set when serving via HTTPS)
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// Content Security Policy — restrict sources of scripts, styles, etc.
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:")
// Don't send referrer to cross-origin destinations
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Disable access to browser features your app doesn't use
h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
next.ServeHTTP(w, r)
})
}
CSP is the most impactful header — it prevents inline script execution and restricts where scripts can load from, defeating most XSS attacks even if sanitization fails. Start with default-src 'self' and relax as needed.
CSRF Protection
CSRF (Cross-Site Request Forgery) tricks authenticated users into submitting requests they didn’t intend. It affects session-based apps (not JWT APIs). Defense: require a CSRF token on all state-changing requests:
import "github.com/gorilla/csrf"
// Wrap the application handler with CSRF protection
protected := csrf.Protect(
[]byte(os.Getenv("CSRF_AUTH_KEY")), // 32 random bytes
csrf.Secure(true), // HTTPS only
csrf.SameSite(csrf.SameSiteLaxMode),
)(mux)
http.ListenAndServe(":8080", protected)
In HTML forms, include the CSRF field:
// In the handler that serves the form
func serveForm(w http.ResponseWriter, r *http.Request) {
tmpl.Execute(w, map[string]any{
csrf.TemplateTag: csrf.TemplateField(r), // renders as <input type="hidden" ...>
})
}
JWT-based APIs (token in Authorization: Bearer header) don’t need CSRF protection because browsers can’t auto-send custom headers from cross-origin forms.
Input Validation and Size Limits
Validate input at the entry point — before it reaches business logic or the database:
func createUserHandler(w http.ResponseWriter, r *http.Request) {
// Enforce request size before parsing — prevents memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
var req CreateUserRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject typos in field names
if err := dec.Decode(&req); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Field-level validation
errs := make(map[string]string)
if strings.TrimSpace(req.Name) == "" {
errs["name"] = "required"
} else if len(req.Name) > 100 {
errs["name"] = "too long (max 100)"
}
if !isValidEmail(req.Email) {
errs["email"] = "invalid format"
}
if len(errs) > 0 {
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]any{"errors": errs})
return
}
// Proceed with validated req...
}
Rate Limiting
Rate limiting prevents brute-force attacks and protects backend services from being overwhelmed. Use golang.org/x/time/rate — see Go semaphores and rate limiting for the full per-client pattern:
import "golang.org/x/time/rate"
// Strict limit for authentication endpoints — prevent password brute-force
var loginLimiter = rate.NewLimiter(rate.Every(time.Second), 5) // 5/sec burst
func loginHandler(w http.ResponseWriter, r *http.Request) {
if !loginLimiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
// ...
}
For IP-based rate limiting (more realistic), maintain a per-IP limiter map — see the rate limiting guide for the full implementation.
Secure Cookie and Session Configuration
Sessions and auth tokens stored in cookies need protective flags:
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionToken,
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true, // not accessible via JavaScript — defeats XSS token theft
Secure: true, // HTTPS only — never sent over HTTP
SameSite: http.SameSiteLaxMode, // Lax: allowed with top-level navigations; Strict: never cross-site
})
HttpOnly prevents JavaScript from reading the cookie — even if XSS succeeds, it can’t steal the session token. Secure prevents the cookie from being sent over unencrypted connections. SameSite=Strict is the most restrictive (no cross-site sends at all); Lax allows the cookie on top-level navigations (clicking a link from another site).
Dependency Vulnerabilities
Keep dependencies up to date and scan for known CVEs:
# Check for known vulnerabilities in your dependency tree
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# Keep dependencies current
go get -u ./... # update all (review changes before committing)
Run govulncheck in CI — it only reports vulnerabilities in code paths your program actually calls, with very low false positives.
Summary
- SQL injection: parameterized queries always — never concatenate user input into SQL
- XSS: use
html/template(nottext/template) for HTML output — escaping is automatic and context-aware - Security headers: add a middleware with X-Frame-Options, X-Content-Type-Options, HSTS, and CSP
- CSRF: use
gorilla/csrffor session-based apps; JWT APIs with Bearer tokens don’t need CSRF - Validate input early: enforce size limits with
http.MaxBytesReader, reject unknown fields withDisallowUnknownFields - Run
govulncheck ./...in CI to catch known CVEs in your dependency tree
Comments