Safeguard
Security Guides

The Go Web Application Security Checklist: Server Hardening to Output Encoding

A field-tested checklist for Go web services — the http.Server timeouts nobody sets, html/template escaping traps, auth and session hygiene, and the headers that actually matter.

Daniel Osei
Security Researcher
6 min read

Most Go web-security advice is generic OWASP repackaged with func in front of it. This checklist is the opposite: it's the Go-specific things that trip up real services — the http.Server fields that default to unbounded, the one-character difference between html/template and text/template that decides whether you have XSS, the template.HTML() cast that silently disables escaping, and the auth patterns that leak under concurrency. Work through it before you ship, and revisit it when you add a dependency or an endpoint. Each item says what to do and, more usefully, why the default bites you.

Why is the first fix always the http.Server timeouts?

Because the zero-value http.Server has no timeouts at all, which means a single slow client can hold a connection open indefinitely — the Slowloris denial-of-service in one line of omission.

// The bare-minimum hardened server — NEVER use http.ListenAndServe defaults in prod
srv := &http.Server{
    Addr:              ":8443",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,   // guards against Slowloris
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       120 * time.Second,
    MaxHeaderBytes:    1 << 20,            // 1 MiB header cap
}

http.ListenAndServe(addr, handler) — the function every tutorial uses — creates a server with all timeouts at zero, meaning infinite. ReadHeaderTimeout is the single most important one to set: without it, a client can send headers one byte per second forever and tie up a goroutine and connection. Set all four, cap MaxHeaderBytes, and cap request body size per-handler with http.MaxBytesReader.

What's the html/template trap that reintroduces XSS?

Two of them: using text/template for browser output, and casting strings to template.HTML to "fix" escaping you didn't understand.

html/template is context-aware — it escapes a value differently depending on whether it lands in HTML text, an attribute, a URL, or a <script> block, which designs reflected XSS away. But:

import "text/template"  // WRONG for HTML — no auto-escaping at all

text/template does zero HTML escaping. A single wrong import turns every rendered value into an XSS sink. And within html/template, wrapping a value in template.HTML(userInput), template.JS(...), or template.URL(...) tells the engine "trust me, this is already safe" — disabling exactly the protection you want. Those casts should be rare, reviewed, and never applied to user input. The rule: import html/template for anything a browser renders, and treat any template.HTML/template.JS/template.URL cast as a security decision.

What does the auth and session section require?

Per-request state, secure cookies, and constant-time comparisons — the places Go's ergonomics tempt you into subtle bugs.

  • Never store request-scoped identity in a package-level variable. Under concurrency it leaks across requests (a real auth-bypass class). Keep the authenticated user in a local or in r.Context().
  • Set cookie flags explicitly: HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode (or Strict), and a sane MaxAge. The zero-value http.Cookie is none of these.
  • Compare secrets in constant time. Use crypto/subtle's ConstantTimeCompare for tokens and MACs; a plain == on a session token or HMAC leaks length and content through timing.
  • Hash passwords with bcrypt, scrypt, or argon2 (golang.org/x/crypto/bcrypt) — never a bare SHA-256, and never MD5/SHA1 (which gosec G401/G501 will flag).
  • Generate tokens and session IDs with crypto/rand, never math/rand (gosec G404). This is the most common real weakness in homegrown Go auth.

Which security headers and CSRF defenses actually matter?

The ones that stop content-type confusion, framing, and cross-site requests — set them once in middleware.

func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        h := w.Header()
        h.Set("X-Content-Type-Options", "nosniff")
        h.Set("X-Frame-Options", "DENY")
        h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        h.Set("Content-Security-Policy", "default-src 'self'")
        next.ServeHTTP(w, r)
    })
}

nosniff stops browsers from MIME-sniffing a response into executable content; HSTS forces HTTPS on repeat visits; a real CSP is your last line against XSS payloads that slip past template escaping. For CSRF, Go 1.25's net/http added a built-in CrossOriginProtection (Origin/Sec-Fetch-Site-based) — use it or a well-maintained CSRF middleware with the synchronizer-token pattern for any cookie-authenticated state-changing endpoint. Don't hand-roll CSRF tokens with math/rand.

The full checklist

AreaDo thisDefault that bites
ServerSet Read/Write/Idle/Header timeoutsAll zero = unbounded (Slowloris)
Bodyhttp.MaxBytesReader per handlerUnbounded request bodies
OutputUse html/template onlytext/template = no escaping
EscapingBan template.HTML(userInput)Silently disables auto-escape
Randomnesscrypto/rand for tokensmath/rand is predictable (G404)
Secrets comparesubtle.ConstantTimeCompare== leaks via timing
Passwordsbcrypt/argon2SHA/MD5 (G401/G501)
CookiesHttpOnly, Secure, SameSiteZero-value cookie has none
TLSProper CA config, TLS 1.2+InsecureSkipVerify (G402)
CSRFCrossOriginProtection / tokenNo default protection
Headersnosniff, HSTS, CSP, X-Frame-OptionsNone set by default
Filesos.Root to confine paths (Go 1.24+)filepath.Join allows traversal (G304)
Concurrencygo test -race gateRaces leak/bypass silently
SQLParameterized queries onlySprintf = injection (G201)
Rate limitgolang.org/x/time/rate per clientNo default throttling
Dependenciesgovulncheck ./... in CIUnpatched module CVEs

Adopt this as a pre-merge gate rather than a one-time audit. The items compound: parameterized SQL plus html/template plus a strict CSP means an injection that slips one layer still hits two more. For the concurrency items specifically, our Go concurrency security pitfalls guide goes deeper.

What can't a checklist catch on its own?

Whether the controls are actually wired up on every route, and whether an input reaches a sink. Checklists document intent; you still have to verify behavior.

A checklist can't confirm that your securityHeaders middleware wraps every handler, that a new endpoint added last week uses parameterized queries, or that a template.HTML cast three commits ago is fed user input. That verification is continuous, and it's where automated scanning earns its place alongside the list — running the checks on every commit so a regression is caught the day it lands, not the day it's exploited.

How Safeguard Helps

Safeguard turns this checklist into continuous enforcement. Dynamic testing (DAST) drives your running Go service to confirm headers are present on every route, cookies carry the right flags, and inputs don't reach injection or SSRF sinks — behavior a static list can't verify. Software composition analysis covers the dependency and govulncheck rows, SAST via the CLI catches the code-level items (weak crypto, unsafe SQL, InsecureSkipVerify) pre-merge, and Griffin, the AI analysis engine, prioritizes and drafts fixes. Start from our product comparison overview if you're evaluating options.

Run the full checklist against your Go service free at app.safeguard.sh/register, with integration docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.