API Security Checklist for Go Services Before Launch
A pre-launch security checklist for Go APIs with a runnable seven-check scan that fails a careless server and passes once headers, limits and auth are fixed.

Security reviews before a launch tend to either be a vague conversation or a thousand-item spreadsheet nobody finishes. What works is a short list that a developer can run through in an afternoon, with the items that can be checked by a program written down as a program. This article gives that list for a Go HTTP API, and a small scanner that fails a typical first-draft server on every check it knows.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The list assumes an API that serves JSON to browsers and other programs. It is organised by where the check lives: things visible from the outside, things in the server configuration, things in the code and things in the pipeline. Each item links to a longer article where one exists, such as authentication, broken object-level authorisation, mass assignment and SSRF. The scanner ran on Go 1.25.6.
A scan you can run from outside
Seven checks can be made by anything that can send HTTP requests. They are the ones most often wrong in a first release, and the cheapest to fix. The program below defines a deliberately careless server and a hardened one, then runs the same scan against both.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
// naive is what a first version tends to look like.
func naive() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) // reflect any origin
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("X-Powered-By", "gohacks-api/1.0")
var in map[string]any
if err := json.NewDecoder(r.Body).Decode(&in); err != nil && r.Method == http.MethodPost {
http.Error(w, "decode error at /srv/app/handlers/items.go:57: "+err.Error(), http.StatusBadRequest)
return
}
fmt.Fprint(w, `{"items":[]}`)
})
mux.HandleFunc("/admin/users", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[{"email":"[email protected]"}]`) })
return mux
}
// hardened applies the baseline from the checklist.
func hardened() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /items", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"items":[]}`) })
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
var in map[string]any
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
if err := dec.Decode(&in); err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "too large") {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, `{"error":"invalid request body"}`, status)
return
}
fmt.Fprint(w, `{"items":[]}`)
})
mux.HandleFunc("GET /admin/users", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
fmt.Fprint(w, `[]`)
})
allowed := map[string]bool{"https://app.example.test": true}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cache-Control", "no-store")
h.Set("Content-Type", "application/json")
if o := r.Header.Get("Origin"); allowed[o] {
h.Set("Access-Control-Allow-Origin", o)
h.Add("Vary", "Origin")
}
mux.ServeHTTP(w, r)
})
}
type check struct {
name string
run func(base string) (ok bool, detail string)
}
func do(method, url string, body []byte, headers map[string]string) (*http.Response, string) {
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err.Error()
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return resp, string(b)
}
var checks = []check{
{"admin route requires credentials", func(b string) (bool, string) {
r, _ := do("GET", b+"/admin/users", nil, nil)
return r.StatusCode == 401, fmt.Sprint(r.StatusCode)
}},
{"CORS does not reflect arbitrary origins", func(b string) (bool, string) {
r, _ := do("GET", b+"/items", nil, map[string]string{"Origin": "https://evil.example"})
got := r.Header.Get("Access-Control-Allow-Origin")
return got == "", fmt.Sprintf("ACAO=%q", got)
}},
{"no X-Powered-By or Server banner", func(b string) (bool, string) {
r, _ := do("GET", b+"/items", nil, nil)
return r.Header.Get("X-Powered-By") == "", fmt.Sprintf("X-Powered-By=%q", r.Header.Get("X-Powered-By"))
}},
{"nosniff on API responses", func(b string) (bool, string) {
r, _ := do("GET", b+"/items", nil, nil)
return r.Header.Get("X-Content-Type-Options") == "nosniff", r.Header.Get("X-Content-Type-Options")
}},
{"errors do not leak paths or internals", func(b string) (bool, string) {
_, body := do("POST", b+"/items", []byte(`{bad`), nil)
return !strings.Contains(body, "/srv/") && !strings.Contains(body, ".go:"), strings.TrimSpace(body)
}},
{"oversized body is refused (413)", func(b string) (bool, string) {
r, _ := do("POST", b+"/items", []byte(`{"a":"`+strings.Repeat("x", 200000)+`"}`), nil)
return r.StatusCode == 413, fmt.Sprint(r.StatusCode)
}},
{"wrong method gets 405, not a normal answer", func(b string) (bool, string) {
r, _ := do("DELETE", b+"/items", nil, nil)
return r.StatusCode == 405, fmt.Sprint(r.StatusCode)
}},
}
func scan(label string, h http.Handler) {
srv := httptest.NewServer(h)
defer srv.Close()
fmt.Printf("== %s\n", label)
passed := 0
for _, c := range checks {
ok, detail := c.run(srv.URL)
mark := "FAIL"
if ok {
mark, passed = "pass", passed+1
}
fmt.Printf(" %s %-44s %s\n", mark, c.name, detail)
}
fmt.Printf(" %d of %d checks passed\n", passed, len(checks))
}
func main() {
scan("first version", naive())
scan("after the checklist", hardened())
}== first version
FAIL admin route requires credentials 200
FAIL CORS does not reflect arbitrary origins ACAO="https://evil.example"
FAIL no X-Powered-By or Server banner X-Powered-By="gohacks-api/1.0"
FAIL nosniff on API responses
FAIL errors do not leak paths or internals decode error at /srv/app/handlers/items.go:57: invalid character 'b' looking for beginning of object key string
FAIL oversized body is refused (413) 200
FAIL wrong method gets 405, not a normal answer 200
0 of 7 checks passed
== after the checklist
pass admin route requires credentials 401
pass CORS does not reflect arbitrary origins ACAO=""
pass no X-Powered-By or Server banner X-Powered-By=""
pass nosniff on API responses nosniff
pass errors do not leak paths or internals {"error":"invalid request body"}
pass oversized body is refused (413) 413
pass wrong method gets 405, not a normal answer 405
7 of 7 checks passedThe first server fails every check:
- The admin route answers 200 with no credentials. A route that was "internal for now" and never given a guard.
- CORS reflects any
Originand allows credentials. A page on any website can then call the API with the visitor's cookies and read the answer. This is the most common CORS mistake, and it is a real vulnerability, not a style point. - A banner header (
X-Powered-By) names the stack and version. It helps an attacker pick an exploit. - No
X-Content-Type-Options: nosniff. Older browsers may treat a JSON response as HTML if the content type is ambiguous. - An error body containing a file path and line number, plus the parser's exact message. Internals belong in your logs, not in a response.
- No request body limit. A 200 KB body was accepted and a larger one would be as well.
- The wrong method gets a normal answer.
DELETE /itemsreturned 200. WithHandleFunc("/items", ...)the pattern matches every method, so handlers have to check the method themselves, and this one did not.
The hardened server passes all seven. The fixes are short: an Authorization guard that returns 401 with WWW-Authenticate, an allow-list for origins with Vary: Origin, security headers set once in a wrapper around the mux, a fixed error body, http.MaxBytesReader and method-qualified route patterns ("GET /items") that let the mux answer 405 itself.
The honest limit of this scan: it only sees what a client sees. It cannot tell you whether the 401 guard is on every route, only that it is on the one probed. Run it against your real endpoints, add the routes that matter, and treat a pass as the floor.
Authentication and authorisation
- Every route has an explicit access decision. Not "protected by default in middleware that someone might forget to apply", but a test that walks the router and fails on a route with no declared policy.
- Object-level checks everywhere an id appears. The user is allowed to call
GET /orders/{id}, but is this order theirs? This is the top item in the OWASP API Security list and the one automated scanners miss. See BOLA and IDOR. - Tokens are verified completely. Signature, algorithm pinned, issuer, audience, expiry. API authentication covers the checks.
- Passwords, if you store them, use a slow hash (bcrypt or Argon2id) and constant-time comparison. API keys are stored hashed, as in API key generation.
- Rate limit authentication endpoints per account and per address to blunt credential stuffing (rate limiting).
Input handling
- Bound the size of everything. Request bodies (
MaxBytesReader), headers (MaxHeaderBytes), query strings, multipart parts, JSON nesting depth (JSON limits) and array lengths. - Decode strictly.
DisallowUnknownFieldson request bodies, and bind to dedicated request types so a client cannot set fields you never meant to expose (mass assignment). - Validate type, range, length and format at the edge, and use parameterised queries always. String-built SQL has no exceptions.
- Do not fetch URLs a client gives you without the protections in SSRF in Go services.
- Set the content type on the way in. Reject a body whose
Content-Typeis not what the route accepts.
Server configuration
- Timeouts on the
http.Server.ReadHeaderTimeoutabove all, plusReadTimeout,WriteTimeoutandIdleTimeout, so a slow client cannot hold connections open (server timeouts and Slowloris). - TLS everywhere outside localhost, with a modern minimum version. In Go that means leaving
tls.Configdefaults alone unless you have a reason, and redirecting or refusing plain HTTP at the edge. SetStrict-Transport-Securityif browsers call the API. - CORS as an allow-list of exact origins,
Vary: Origin, and credentials only where needed. Never combine reflection and credentials. - Headers:
X-Content-Type-Options: nosniff,Cache-Control: no-storeon responses with personal data, and no stack banners. - Disable debug endpoints in production, or put them on a separate listener bound to localhost:
pprof,expvar,/debug/*, GraphQL introspection if the API is not meant to be self-describing. - Run as a non-root user in a minimal container, with a read-only root file system where possible.
Errors, logs and secrets
- Errors tell the client what to fix and nothing more. A stable code and a generic message, with the detail in the log keyed by a request id (problem details).
- Logs never contain secrets. Tokens, passwords, API keys, full card numbers, and
AuthorizationorCookieheaders must be redacted before they reach the log pipeline (logging without leaking secrets). - Secrets come from the environment or a secret manager, not source control, and have a rotation story. Search the repository history for keys before you launch; a deleted secret in git history is still public.
- Panics are recovered at the edge, logged with the request id and answered with a generic 500.
Dependencies and build
- Run
govulncheckin CI. It reports only vulnerabilities in code paths your program actually calls, which keeps the noise low. I did not run it in this article's lab; its documentation describes the command and the database it reads. - Pin and review dependencies, keep the Go toolchain current (security fixes ship in minor releases), and enable automated update pull requests.
- Build reproducibly, with
-trimpath, and keep the module cache and checksum database (go.sum, GOFLAGS=-mod=readonly) on in CI. - Scan container images and keep base images small.
Operations
- Alerts on 401, 403 and 429 spikes, unusual response sizes and new error codes. Attacks look like traffic first.
- An incident path: who can rotate a key, revoke a token or turn off an endpoint at 3 a.m., and where the instructions are.
- Backups tested by restoring them.
- A security contact documented in
/.well-known/security.txt(RFC 9116).
Making the list stick
Automate what you can. The seven-check scan belongs in CI against a deployed preview, with the route list kept in a file the team edits. Add a router-walking test for access policies and a unit test that every handler has a body limit. Review the items that cannot be automated at the end of each release, and when a vulnerability reaches production, add the check that would have caught it.
The OWASP API Security Top 10 is the reference to measure the list against, and the Go project's security best practices covers the language-specific advice, including the tooling mentioned above.


