🛡️ Analogy
Shipping a web service without these defaults is leaving the doors unlocked because no one’s tried them yet. Each measure here is a lock: security headers tell the browser what’s allowed, parameterized queries stop input becoming commands, auto-escaping defangs injected markup, and constant-time checks stop an attacker picking the lock by listening to the clicks. None is exotic — they’re the baseline a service is expected to ship with.
Security headers
A few response headers tell the browser how to defend the user: don’t sniff content types, don’t allow framing (clickjacking), enforce HTTPS, and restrict where scripts may load from. Set them once in a middleware so every response carries them:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func secureHeaders(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") // don't MIME-sniff
h.Set("X-Frame-Options", "DENY") // no framing (clickjacking)
h.Set("Content-Security-Policy", "default-src 'self'") // restrict sources
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func main() {
app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
rec := httptest.NewRecorder()
secureHeaders(app).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
for _, k := range []string{"X-Content-Type-Options", "X-Frame-Options",
"Content-Security-Policy", "Strict-Transport-Security"} {
fmt.Printf("%-28s %s\n", k+":", rec.Header().Get(k))
}
}
Send Strict-Transport-Security only over HTTPS (it tells browsers to refuse plain HTTP to your host), and tune the CSP to your app — default-src 'self' is a strict starting point.
SQL injection: parameterize, never concatenate
The number-one database vulnerability comes from building SQL with string concatenation. The fix is placeholders — the database/sql driver sends the query and the values separately, so input can never be parsed as SQL:
// ☠️ NEVER — user input becomes part of the SQL
q := "SELECT * FROM users WHERE name = '" + name + "'"
// name = "'; DROP TABLE users;--" → catastrophe
// ✅ ALWAYS — placeholders; the value travels as data, not code
row := db.QueryRow("SELECT id, email FROM users WHERE name = $1", name)
// also: db.Exec("UPDATE users SET email=$1 WHERE id=$2", email, id)
This is the rule: parameterize every query that touches external input. Manual quote-escaping is not a substitute — it’s a perennial source of bypasses.
XSS: let html/template escape for you
Rendering user data into HTML with fmt or text/template lets an attacker inject <script>. html/template escapes each value for the context it lands in (HTML, attribute, URL, JS) — automatically and correctly:
package main
import (
"html/template"
"os"
)
func main() {
t := template.Must(template.New("p").Parse("<p>Hello, {{.}}</p>\n"))
// Hostile input is neutralized — it renders as inert text, not a script tag.
t.Execute(os.Stdout, "<script>steal()</script>")
// <p>Hello, <script>steal()</script></p>
}
Don’t defeat it by wrapping untrusted data in template.HTML — that marks it trusted and re-opens the hole.
Body limits and timeouts
Two cheap protections against resource-exhaustion. Cap the request body so a giant payload can’t OOM you, and give the server timeouts so a slow client (Slowloris) can’t tie up connections:
// per-handler: reject bodies over 1 MiB
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
http.Error(w, "request too large or malformed", http.StatusRequestEntityTooLarge)
return
}
// per-server: cap slow connections (see the HTTP server page)
srv := &http.Server{ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second}
Pair these with per-client rate limiting middleware to blunt brute-force and flooding.
Compare secrets in constant time
Comparing an API token or HMAC with == (or bytes.Equal) returns as soon as two bytes differ — an attacker timing the response can recover the secret byte by byte. crypto/subtle.ConstantTimeCompare always scans the full length:
package main
import (
"crypto/subtle"
"fmt"
)
// constant-time equality: same duration whether it mismatches at byte 0 or byte 31
func tokenEqual(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
func main() {
const secret = "s3cr3t-api-token"
fmt.Println("right token:", tokenEqual("s3cr3t-api-token", secret)) // true
fmt.Println("wrong token:", tokenEqual("guess", secret)) // false
}
Use it for tokens, HMAC verification, and password-hash comparisons (though prefer bcrypt/argon2 for passwords, which compare safely for you).
Don’t leak internal errors
An error message echoed to the client can reveal your stack, SQL, file paths, and library versions — free reconnaissance. Log the detail; return a generic response:
if err != nil {
log.Printf("query failed (req %s): %v", reqID, err) // full detail in logs
http.Error(w, "internal server error", http.StatusInternalServerError) // generic to client
return
}
Secure-defaults checklist
| Threat | Defense |
|---|---|
| MITM / eavesdropping | TLS with MinVersion TLS 1.2+; HSTS header |
| SQL injection | parameterized queries (placeholders), never concatenation |
| XSS | html/template auto-escaping; strict CSP |
| Clickjacking | X-Frame-Options: DENY / CSP frame-ancestors |
| MIME sniffing | X-Content-Type-Options: nosniff |
| Oversized body (OOM) | http.MaxBytesReader |
| Slowloris | server ReadHeaderTimeout / ReadTimeout |
| Brute force / flooding | per-client rate limiting |
| Timing attacks on secrets | subtle.ConstantTimeCompare |
| Info leakage | log details, return generic errors |
| Secrets in code | env/secret store, never hard-coded or committed |
⚠️ Security is defaults, not an afterthought
These playgrounds run in-process (recorder, template, byte compare) — no network — so they’re safe anywhere. The mindset: assume every input is hostile and every default is insecure until you’ve set it. The big ones, in order of how often they bite: parameterize SQL, use html/template, set timeouts and body limits, never InsecureSkipVerify, and never echo raw errors. This is a defensive baseline, not a substitute for a real security review of code that handles money, auth, or PII.
See also
- TLS & HTTPS — the encryption layer and certificate verification.
- routing & middleware — where header/rate-limit/recovery middleware lives.
- database/sql — placeholders and safe query patterns in depth.
- templates —
html/template’s context-aware escaping. - rate limiting — bounding abusive clients.
Next: structure your endpoints into a clean API — REST APIs.
Related topics
How TLS secures HTTP — the handshake, certificates and the CA chain, serving with ListenAndServeTLS, verifying clients, mTLS, and autocert.
httpRouting & MiddlewareDispatch and cross-cutting concerns — the Go 1.22 ServeMux (method+path patterns, wildcards, precedence), path values, middleware as Handler-wrapping-Handler, chaining order, and recovery/logging middleware.
datadatabase/sqlGo's driver-agnostic SQL layer — sql.Open returns a connection pool, parameterized queries stop injection, Scan reads rows, and you always close them.
Check your understanding
Score: 0 / 51. What's the correct defense against SQL injection in Go's database/sql?
Never build SQL by concatenating user input. Placeholders (?, $1, …) make the driver send the query and the values over separate channels, so a value like "'; DROP TABLE users;--" is treated as data, not SQL. Manual escaping is error-prone and not a substitute.
2. Why use html/template instead of text/template (or fmt) to render user data into a page?
html/template escapes each interpolated value for where it lands (HTML body, attribute, URL, JS), so <script>steal()</script> becomes inert text. text/template and fmt do zero escaping — using them for HTML is a cross-site-scripting hole.
3. Why return a generic message to the client but log the real error server-side?
Echoing raw errors back exposes your stack, queries, file paths, and dependency versions — reconnaissance for an attacker. Log the full error internally (with a request ID) and return a generic, non-revealing message and status to the caller.
4. What does http.MaxBytesReader(w, r.Body, n) protect against?
Without a limit, decoding an attacker's multi-gigabyte body can OOM the process. MaxBytesReader wraps r.Body so reads fail once n bytes are exceeded, letting you reject oversized payloads. (Slow-header/Slowloris attacks are handled by server ReadHeaderTimeout instead.)
5. Why compare a secret token with subtle.ConstantTimeCompare rather than ==?
A normal comparison returns as soon as bytes differ, so an attacker measuring response time can recover a secret byte-by-byte (a timing side-channel). crypto/subtle.ConstantTimeCompare always scans the full length, removing that signal. Use it for tokens, HMACs, and password-hash comparisons.
Comments
Sign in with GitHub to join the discussion.