Skip to content
AgileLogics
8 min read

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, &lt;script&gt;alert(&#34;hi&#34;)&lt;/script&gt;</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 with filepath.Clean and reject anything that resolves outside the intended directory (filepath.IsLocal helps).
  • 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/template for HTML, or wrapping user input in template.HTML.
  • Generating tokens with math/rand.
  • Running http.ListenAndServe in 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.