Security Essentials
Prerequisites: Idiomatic design patterns in Go
Go's standard library makes the secure option the default in several places, but only if you use it. This lesson covers the issues that come up in almost every Go service.
SQL injection: always use placeholders
// Vulnerable: input becomes part of the SQL text.
q := fmt.Sprintf("SELECT id FROM users WHERE email = '%s'", email)
rows, err := db.QueryContext(ctx, q)
// Safe: the value is sent separately and never parsed as SQL.
rows, err := db.QueryContext(ctx, "SELECT id FROM users WHERE email = $1", email)
Placeholders can't be used for identifiers such as table names or ORDER BY
columns. For those, map user input to a fixed allow-list:
sortColumns := map[string]string{"name": "name", "newest": "created_at DESC"}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "name"
}
query := "SELECT id, name FROM products ORDER BY " + col
XSS: html/template escapes for you
html/template understands HTML context and escapes values automatically.
text/template does not — never use it to render HTML:
package main
import (
"html/template"
"os"
)
func main() {
t := template.Must(template.New("page").Parse(
`<p>Hello, {{.Name}}</p>` + "\n" + `<a href="/search?q={{.Name}}">search</a>` + "\n"))
t.Execute(os.Stdout, map[string]string{
"Name": `<script>alert("hi")</script>`,
})
}
Expected output:
<p>Hello, <script>alert("hi")</script></p>
<a href="/search?q=%3cscript%3ealert%28%22hi%22%29%3c%2fscript%3e">search</a>
The same value is HTML-escaped in the paragraph and URL-escaped in the
href. Converting input to template.HTML switches that protection off —
only do it for content you generated and trust.
Randomness: crypto/rand for secrets
math/rand and math/rand/v2 are for simulations and shuffling — not
secrets. Tokens, session IDs, password-reset links, and API keys must use
crypto/rand:
import (
"crypto/rand"
"encoding/base64"
)
func NewToken() (string, error) {
b := make([]byte, 32) // 256 bits
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
When comparing secrets such as tokens or HMAC signatures, use
crypto/subtle.ConstantTimeCompare (or hmac.Equal) so the comparison time
doesn't leak how many bytes matched.
Passwords: bcrypt
Never store passwords in plain text or with a fast hash like SHA-256. Use a
slow, salted password hash. bcrypt lives in the Go team's x/crypto module:
go get golang.org/x/crypto/bcrypt
import "golang.org/x/crypto/bcrypt"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// store hash in the database
err = bcrypt.CompareHashAndPassword(hash, []byte(attempt))
if err != nil {
// wrong password (or corrupt hash) — return a generic "invalid credentials"
}
bcrypt only uses the first 72 bytes of a password; golang.org/x/crypto/argon2
is an alternative if you need to handle longer inputs.
Timeouts on http.Server
http.ListenAndServe uses a server with no timeouts, so a client can hold
connections open indefinitely by sending slowly. Configure your own server:
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(srv.ListenAndServe())
Likewise, http.DefaultClient has no timeout; create an &http.Client{Timeout: 10 * time.Second} for outbound calls. Limit request bodies with
http.MaxBytesReader.
Known vulnerabilities: govulncheck
govulncheck checks your dependencies (and the Go version you build with)
against the Go vulnerability database, and reports only the vulnerabilities
your code actually calls:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
Run it in CI and keep your Go toolchain updated — security fixes ship in minor releases.
More checks worth making
- Validate and bound all input: lengths, ranges, allowed values.
- Check authorization on the server for every request, including "is this my resource?" — not just "is the user logged in?".
- Build file paths from user input with care. Go 1.24 adds
os.Root; on Go 1.23 clean the path withfilepath.Cleanand reject anything that resolves outside the intended directory (filepath.IsLocalhelps). - Don't log secrets, tokens, or full request bodies.
- Load secrets from the environment or a secret manager, never from the repository.
Common mistakes
- Using
text/templatefor HTML, or wrapping user input intemplate.HTML. - Generating tokens with
math/rand. - Running
http.ListenAndServein production with no timeouts. - Comparing tokens with
==. - Returning different errors for "unknown user" and "wrong password", which lets attackers enumerate accounts.
Practice
Write NewResetToken() (token string, hash []byte, err error) that creates a
random 32-byte URL-safe token for an email link and a SHA-256 hash of it to
store in the database. Then write CheckResetToken(token string, stored []byte) bool using a constant-time comparison.
Show solution
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
)
func NewResetToken() (string, []byte, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", nil, err
}
token := base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(token))
return token, sum[:], nil
}
func CheckResetToken(token string, stored []byte) bool {
sum := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(sum[:], stored) == 1
}
func main() {
token, hash, err := NewResetToken()
if err != nil {
panic(err)
}
fmt.Println(len(token), CheckResetToken(token, hash), CheckResetToken("guess", hash))
}
Expected output:
43 true false
Storing only the hash means a leaked database doesn't leak usable reset links. A fast hash is fine here (unlike for passwords) because the token already has 256 bits of randomness. The token itself changes every run; its length is always 43 characters.