Review & Verify Updated 2026-09 10 min read View as Markdown

Security review checklist for AI-generated Go

Go's standard library gets most things right by default, which means the vulnerabilities that survive are the ones where you chose the wrong stdlib package.

Go is a good language for writing secure code and generated Go is usually reasonable — parameterised queries by default, no buffer overflows, sane crypto defaults. What survives review is narrow and specific: choosing the wrong package from a pair that look interchangeable, and the handful of things gosec cannot infer.

Turn on the machine checks first#

shell
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...                 # CVEs, but only for code paths you actually call
golangci-lint run                 # with gosec enabled

govulncheck is the standout tool here and has no equivalent in most ecosystems: it does reachability analysis, so it reports the vulnerable functions your code can actually reach rather than every CVE in your dependency tree. That means near-zero noise, which means people act on it.

.golangci.yml
linters:
  enable: [gosec, bodyclose, noctx, contextcheck, errcheck, errorlint, sqlclosecheck]

The wrong-package mistakes#

1. text/template where html/template was needed#

The most consequential single mistake in generated Go web code.

go
import "text/template"                    // no escaping whatsoever

t := template.Must(template.New("p").Parse(`<div>{{.Comment}}</div>`))
t.Execute(w, data)                        // stored XSS

The two packages have identical APIs, so swapping the import compiles and passes every test. html/template is contextually aware — it escapes differently inside an attribute, a URL, a script block — and text/template does nothing at all.

Correct: import "html/template" for anything that reaches a browser.

Catch it with: grep -rn '"text/template"' --include='*.go' . in review, and a line in your AGENTS.md. No linter reliably distinguishes intent here.

2. math/rand where crypto/rand was needed#

go
token := fmt.Sprintf("%x", rand.Int63())   // predictable

Session tokens, password reset links, nonces, API keys, filenames in a shared directory. math/rand/v2 is better distributed than the old package and is still not cryptographically secure.

go
b := make([]byte, 32)
if _, err := crypto_rand.Read(b); err != nil {
    return fmt.Errorf("generate token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(b)

Catch it with: gosec G404.

3. == on secrets#

go
if mac == expectedMAC {                   // early return leaks timing

subtle.ConstantTimeCompare for MACs, tokens and password hashes. Catch it with: gosec G201-family rules, and review.

Injection#

4. SQL built with fmt.Sprintf#

go
q := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
db.Query(q)

Still generated when the surrounding code does not use an ORM or sqlc. Use placeholders — db.Query("... WHERE email = $1", email). Catch it with: gosec G201/G202.

Note that a query built for a dynamic ORDER BY cannot be parameterised. That case needs an allowlist:

go
var allowed = map[string]string{"name": "name", "created": "created_at"}
col, ok := allowed[req.SortBy]
if !ok { return ErrBadSort }

5. Command execution#

go
exec.Command("sh", "-c", "git log --author="+author)   // injection
exec.Command("git", "log", "--author="+author)         // fine: no shell

Go's exec.Command takes an argument slice and does not invoke a shell, which makes it safe by default — until generated code reaches for sh -c to get pipes or globbing. Catch it with: grep -rn '"sh", "-c"' --include='*.go' and gosec G204.

Also check exec.LookPath behaviour: a relative or attacker-influenced PATH means you may run a different binary than you intended.

Web-layer#

6. SSRF#

go
func fetchAvatar(w http.ResponseWriter, r *http.Request) {
    url := r.URL.Query().Get("url")
    resp, _ := http.Get(url)               // now fetches your metadata endpoint

Generated "import from URL", webhook and avatar features almost never validate. Allowlist the scheme and host, resolve the name and reject private ranges, and disable redirects:

go
client := &http.Client{
    CheckRedirect: func(*http.Request, []*http.Request) error {
        return http.ErrUseLastResponse         // do not follow into the private range
    },
    Timeout: 10 * time.Second,
}

A redirect check matters: an attacker controls a public host that 302s to 169.254.169.254.

7. Path traversal#

go
http.ServeFile(w, r, filepath.Join(dir, r.URL.Path))   // ../../etc/passwd

Use http.FileServer with http.Dir, which handles this, or os.Root (Go 1.24+) for a directory you cannot escape. If you must join manually, filepath.Clean then verify the result is still inside the base with a separator-aware prefix check.

8. Missing timeouts#

go
http.ListenAndServe(":8080", mux)          // no timeouts at all

The zero-value http.Server has no read, write or idle timeout, so a slow client can hold a connection indefinitely. Generated servers use the one-liner constantly.

go
srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,     // the Slowloris one
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

Catch it with: gosec G112 for ReadHeaderTimeout specifically.

9. Unbounded reads#

go
body, _ := io.ReadAll(r.Body)              // attacker decides your memory usage

http.MaxBytesReader(w, r.Body, 1<<20) first. Same for json.Decoder — and use DisallowUnknownFields to catch mass assignment through an API you did not intend to expose.

10. Decompression bombs#

gzip.NewReader followed by io.ReadAll will happily expand a small upload into all your memory. Wrap with io.LimitReader on the decompressed side.

Types and numbers#

11. Integer conversion truncating#

go
var n int64 = userSuppliedValue
buf := make([]byte, int32(n))              // silently truncates or goes negative

Go 1.24's vet includes checks for some of these, and gosec G115 flags integer overflow conversions. Worth enabling — it is a real source of allocation bugs at boundaries.

12. Errors ignored on security-relevant calls#

go
_ = json.Unmarshal(data, &claims)          // parse failed; claims is zero-valued
if claims.Role == "admin" { }              // zero value is "", so this is fine — this time

The pattern is dangerous whenever the zero value happens to be permissive. errcheck catches the discard; review catches whether the zero value is safe.

Authorisation#

The most common real vulnerability in any web codebase, and no linter finds it:

go
func (h *Handler) GetInvoice(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    inv, err := h.store.Invoice(r.Context(), id)   // any authenticated user, any invoice

The ownership check belongs beside the query:

go
inv, err := h.store.InvoiceForUser(r.Context(), id, user.ID)
if errors.Is(err, sql.ErrNoRows) {
    http.NotFound(w, r)                    // 404, not 403 — do not confirm it exists
    return
}

Write the test once per resource type:

go
func TestCannotReadAnotherUsersInvoice(t *testing.T) {
    rr := doRequest(t, "GET", "/invoices/"+bobInvoiceID, aliceToken)
    if rr.Code != http.StatusNotFound {
        t.Errorf("status = %d, want 404", rr.Code)
    }
}

TLS and crypto#

Go's defaults are good. What generated code gets wrong is disabling them:

go
tls.Config{InsecureSkipVerify: true}       // never in production
tls.Config{MinVersion: tls.VersionTLS10}   // set 1.2 or 1.3

InsecureSkipVerify appears in generated code as a way past a certificate error during development and then survives into main. Catch it with: gosec G402, and a grep in review.

The review, as commands#

shell
govulncheck ./...
golangci-lint run
grep -rn '"text/template"' --include='*.go' .          # XSS
grep -rn 'InsecureSkipVerify' --include='*.go' .
grep -rn '"sh", "-c"' --include='*.go' .
grep -rn 'math/rand' --include='*.go' . | grep -iE 'token|secret|key|nonce|session'
grep -rn 'ListenAndServe' --include='*.go' .           # missing timeouts

Under a minute, and it covers the automatable half. The half worth your attention is authorisation and SSRF, because both depend on knowing what your system is supposed to allow.

The Go-specific thing to remember

Two pairs of standard library packages look identical and are not: text/template vs html/template, and math/rand vs crypto/rand. Picking the wrong one compiles, passes tests, and is a vulnerability. Grep for both on every review.

Common questions#

Is govulncheck better than a generic scanner?#

For Go, substantially — it uses call-graph reachability, so it reports vulnerabilities your code can actually reach rather than every CVE in the module graph. That means far less noise, which is the difference between a tool people act on and one they mute.

Does the standard library's net/http need a framework to be secure?#

No. It needs timeouts set explicitly, body size limits, and html/template for rendering. A framework may give you those by default, which is a real convenience, but nothing about net/http is insecure once configured.

Why does generated Go use InsecureSkipVerify?#

Because it is the answer to "how do I get past this certificate error", and a great deal of example code contains it. Treat any occurrence as a defect and require a comment explaining the specific test scenario if it must exist at all.

Should I use os.Root for file serving?#

If you are on Go 1.24 or later and serving files from a directory, yes — it gives you a handle that cannot be escaped by traversal or symlinks, which is a stronger guarantee than any amount of path cleaning.

Get the Go agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.