Skip to main content

Web Application Security in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;

// ❌ 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.

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 (not text/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/csrf for session-based apps; JWT APIs with Bearer tokens don’t need CSRF
  • Validate input early: enforce size limits with http.MaxBytesReader, reject unknown fields with DisallowUnknownFields
  • Run govulncheck ./... in CI to catch known CVEs in your dependency tree

Resources

Comments

👍 Was this article helpful?