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 saneMaxAge. The zero-valuehttp.Cookieis none of these. - Compare secrets in constant time. Use
crypto/subtle'sConstantTimeComparefor tokens and MACs; a plain==on a session token or HMAC leaks length and content through timing. - Hash passwords with
bcrypt,scrypt, orargon2(golang.org/x/crypto/bcrypt) — never a bare SHA-256, and never MD5/SHA1 (whichgosecG401/G501 will flag). - Generate tokens and session IDs with
crypto/rand, nevermath/rand(gosecG404). 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
| Area | Do this | Default that bites |
|---|---|---|
| Server | Set Read/Write/Idle/Header timeouts | All zero = unbounded (Slowloris) |
| Body | http.MaxBytesReader per handler | Unbounded request bodies |
| Output | Use html/template only | text/template = no escaping |
| Escaping | Ban template.HTML(userInput) | Silently disables auto-escape |
| Randomness | crypto/rand for tokens | math/rand is predictable (G404) |
| Secrets compare | subtle.ConstantTimeCompare | == leaks via timing |
| Passwords | bcrypt/argon2 | SHA/MD5 (G401/G501) |
| Cookies | HttpOnly, Secure, SameSite | Zero-value cookie has none |
| TLS | Proper CA config, TLS 1.2+ | InsecureSkipVerify (G402) |
| CSRF | CrossOriginProtection / token | No default protection |
| Headers | nosniff, HSTS, CSP, X-Frame-Options | None set by default |
| Files | os.Root to confine paths (Go 1.24+) | filepath.Join allows traversal (G304) |
| Concurrency | go test -race gate | Races leak/bypass silently |
| SQL | Parameterized queries only | Sprintf = injection (G201) |
| Rate limit | golang.org/x/time/rate per client | No default throttling |
| Dependencies | govulncheck ./... in CI | Unpatched 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.