So, you've shipped a Go API to production. It works. It's fast. The handlers are short, the code is readable, and the on-call rotation hasn't paged anyone in weeks. Life is good. Until someone forwards you the security audit, and suddenly you're staring at a PDF that says things like "missing request size limits", "unbounded query timeouts", "JWT secret committed to git history", and "1 high, 4 medium CVEs in transitive dependencies".
Here's the slightly uncomfortable truth about Go and security. The language gives you a great floor. There's no eval, no prototype pollution, no pickle.loads waiting to ruin your week. The standard library is conservative and well-tested. Most of the classic exploit categories that haunt PHP or Node simply don't happen the same way in Go. But that floor is not a ceiling, and the things that do go wrong in Go APIs are almost always boring, predictable, and avoidable, if you know to look for them.
This article is the checklist I wish someone had handed me earlier. We're going to walk through the five things every Go HTTP API needs to take seriously before it goes anywhere near the public internet: input validation, timeouts, authentication, secrets handling, and dependency scanning. Each one gets real code, real packages, and a clear "do this, not that". By the end you should be able to open your service, run through this list once, and either feel calm or know exactly what to fix.
1. Input validation: trust nothing past the network card
The first rule is the oldest one in the book. Anything that comes from outside your process is hostile until proven otherwise. Headers, query strings, JSON bodies, multipart uploads, URL params, cookies, all of it. Go won't get you a eval() foot-gun, but it'll happily decode 50MB of nested JSON into a struct, run a giant unbounded SQL query, or hand a path traversal to your file server if you don't put a fence up.
Cap the request size
The most embarrassing outage I've watched happen to a Go service was not an exploit. It was a single client sending a 2GB JSON body to a free-text comment endpoint by accident. The decoder dutifully tried to read all of it into memory, and the pod got OOM-killed mid-request, taking the other in-flight requests with it.
The fix is one line of standard library:
func MaxBody(next http.Handler, maxBytes int64) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
http.MaxBytesReader wraps the body and returns a *MaxBytesError once the limit is exceeded. The JSON decoder will surface that to you, and you can answer with a clean 413 Payload Too Large. Pick a generous-but-finite limit for each route: 1MB for most JSON endpoints, more for upload endpoints, less for things like login.
Decode strictly
The default json.Decoder is forgiving. It silently ignores fields it doesn't know about, which means a client can send {"admin": true, "email": "..."} to your registration endpoint and you'll never notice the extra field landed somewhere it shouldn't. Turn that off:
func decodeJSON[T any](r *http.Request) (T, error) {
var v T
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, fmt.Errorf("decode: %w", err)
}
// Reject trailing junk like {"a":1}{"b":2}
if dec.More() {
return v, errors.New("unexpected extra data after JSON value")
}
return v, nil
}
DisallowUnknownFields turns a quiet misuse into a loud 400. dec.More() catches sneaky payloads that smuggle a second value past your handler. Both are free.
Validate at the edge with a real validator
Hand-rolled if u.Email == "" { ... } chains turn into a 200-line nightmare fast. Use a real validator. The de-facto choice is github.com/go-playground/validator/v10, which is small, fast, and well-maintained.
type CreateUserReq struct {
Email string `json:"email" validate:"required,email,max=254"`
Name string `json:"name" validate:"required,min=1,max=100"`
Password string `json:"password" validate:"required,min=12,max=128"`
Age int `json:"age" validate:"gte=13,lte=130"`
}
var validate = validator.New(validator.WithRequiredStructEnabled())
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
req, err := decodeJSON[CreateUserReq](r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := validate.Struct(req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// ... business logic
}
Two rules of thumb when you use this:
- Validate as close to the network boundary as possible. Once the data is past the handler, every downstream function gets to assume it's well-formed.
- Don't echo the validator's raw error messages back to the client in their default form. They leak struct field names and tag names. Translate them to plain, user-friendly messages, or return a generic
{"error": "invalid request"}and log the detail server-side.
Parameterize every query
I'll keep this one short because it's not Go-specific, but it's worth saying out loud. database/sql already gives you the safe API. Use it.
// Good — parameters are sent separately, the driver escapes them.
row := db.QueryRowContext(ctx, "SELECT id, email FROM users WHERE id = ?", id)
// Bad — string interpolation. Don't do this. Ever. For any reason.
row := db.QueryRowContext(ctx, fmt.Sprintf("SELECT id FROM users WHERE id = %s", id))
The same applies to any ORM you reach for: sqlc, gorm, bun, ent. All of them have a parameterized path. Use it without exception. If you ever find yourself building dynamic SQL with + or fmt.Sprintf, stop and ask whether you actually need that flexibility, or whether a ? and a typed parameter will do.
Sanitize file paths before you touch the filesystem
If your API serves files, accepts uploads, or lets users pick which template to render, you have to defend against path traversal. The pattern is:
func serveUserFile(w http.ResponseWriter, r *http.Request, base, name string) {
// Reject obvious sneakiness first.
if strings.Contains(name, "..") || strings.ContainsRune(name, 0) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
clean := filepath.Clean(filepath.Join(base, name))
// Make sure we didn't escape base after cleaning.
if !strings.HasPrefix(clean, filepath.Clean(base)+string(os.PathSeparator)) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
http.ServeFile(w, r, clean)
}
Don't trust filepath.Clean alone. It normalizes .., but the resulting path can still be outside base if name was already absolute. The prefix check after the join is what actually holds the line.
2. Timeouts: every boundary needs a clock
Go's concurrency model makes it terrifyingly easy to leak goroutines and hold connections forever. The defaults in net/http are designed for development, not for production. Out of the box, an HTTP server will let a client hold a connection open and dribble bytes at you for as long as it wants. Six of those clients with bad intent is a denial of service.
Timeouts in a Go API live at four layers, and you need them at every one.

Server timeouts
Never use http.ListenAndServe directly in production. Build an http.Server and set every timeout explicitly:
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second, // How long we'll wait for headers.
ReadTimeout: 15 * time.Second, // Headers + body combined.
WriteTimeout: 30 * time.Second, // Time to write the response.
IdleTimeout: 120 * time.Second, // Keep-alive between requests.
MaxHeaderBytes: 1 << 20, // 1MB headers cap.
}
log.Fatal(srv.ListenAndServe())
A few things worth knowing:
ReadHeaderTimeoutdefends against Slowloris. Without it, a client can open a connection, send one byte of headers per minute, and tie up a goroutine indefinitely. With it, you get a clean disconnect after 5 seconds.WriteTimeoutshould be longer than your slowest legitimate handler, but not so long that a stuck downstream can chain-react. Tune it per service.IdleTimeoutmatters for connection pools. If clients reuse a connection for hours but you never set this, you'll hoard sockets.
Per-handler context deadlines
Server timeouts only protect the request as a whole. If your handler then calls a downstream service or a slow query, you need a deadline on that work, and you need to propagate it.
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
order, err := h.repo.GetOrder(ctx, orderID(r))
if err != nil {
// ctx.Err() will be context.DeadlineExceeded if we timed out.
writeError(w, http.StatusGatewayTimeout, err)
return
}
writeJSON(w, order)
}
The repo layer must accept the context.Context and pass it through to db.QueryContext, redis.GetContext, the gRPC client, whatever it talks to. If you see a function in your codebase that takes context.Background() from inside a request, that's a bug. The whole call chain has to share one deadline, otherwise the timeout you set in the handler is theater.
Outbound HTTP clients
The default http.Client has no timeout. None. Zero. A misbehaving downstream API can stall your request forever:
// Don't.
client := &http.Client{}
// Do.
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 10,
},
}
Timeout on the http.Client is a hard cap on the whole round trip (dial + TLS + write + read). The per-phase Transport timeouts give you finer control. A TLS handshake that takes longer than 3 seconds is almost always a problem worth surfacing as a fast failure rather than a slow one.
Database query timeouts
Use the context-aware variants of database/sql everywhere:
func (r *Repo) ListActive(ctx context.Context) ([]User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
rows, err := r.db.QueryContext(ctx, "SELECT id, email FROM users WHERE active = true")
if err != nil {
return nil, err
}
defer rows.Close()
// ... scan
}
A slow query plan that normally takes 50ms but suddenly takes 30 seconds will eat your connection pool in minutes. The 2-second deadline above means the query is cancelled at the driver level, the connection returns to the pool, and the upstream gets a clean error to back off on. You also want pool-level limits like db.SetMaxOpenConns, db.SetMaxIdleConns, and db.SetConnMaxLifetime, but those aren't strictly security knobs.
3. Authentication and authorization: do less, but do it right
Auth is the area where people invent the most. Resist that urge. The Go ecosystem has a handful of mature, audited libraries for the auth primitives, and rolling your own is almost always a worse idea than picking one and using it carefully.
Hash passwords with bcrypt (or argon2)
If you store passwords yourself, you need a real password hashing function, not SHA-256, not MD5, not crypto/sha512. Those are fast on purpose, which makes brute force trivial. Use golang.org/x/crypto/bcrypt:
import "golang.org/x/crypto/bcrypt"
func HashPassword(plain string) (string, error) {
if len(plain) > 72 {
// bcrypt silently truncates at 72 bytes — reject earlier so callers know.
return "", errors.New("password too long")
}
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
return string(h), err
}
func CheckPassword(hash, plain string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
}
bcrypt.DefaultCost is currently 10, which gives you about 100ms of work per hash on modern hardware. Crank it up to 12 or 13 if you can afford the latency. Every increment doubles the cost for an attacker. If you need something newer, golang.org/x/crypto/argon2 is also a sound choice and is what new projects often pick.
The 72-byte limit on bcrypt is a real footgun. The function won't fail on longer input. It'll silently truncate, which means "correct horse battery staple ... [long]" and "correct horse battery staple ... [different long]" can hash to the same value. Reject early.
JWTs without the usual mistakes
If you use JWTs (and they're fine for stateless service-to-service auth, less fine for user sessions), the standard library here is github.com/golang-jwt/jwt/v5. The classic mistakes are well-documented and worth avoiding:
import "github.com/golang-jwt/jwt/v5"
type Claims struct {
UserID string `json:"sub"`
jwt.RegisteredClaims
}
func Sign(secret []byte, userID string, ttl time.Duration) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "myapi",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString(secret)
}
func Parse(secret []byte, token string) (*Claims, error) {
var c Claims
parsed, err := jwt.ParseWithClaims(token, &c, func(t *jwt.Token) (interface{}, error) {
// Critical: pin the algorithm. Reject anything else.
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
}, jwt.WithIssuer("myapi"), jwt.WithExpirationRequired())
if err != nil || !parsed.Valid {
return nil, errors.New("invalid token")
}
return &c, nil
}
The "pin the algorithm" check is the one people forget. Without it, an attacker can sign a token with alg: none or downgrade RS256 to HS256 using your public key as the HMAC secret. The library defaults are better than they used to be, but explicit is safer.
Other things that matter:
- Short expiry. Hours, not days. Pair with refresh tokens if you need long sessions.
- Don't put sensitive data in the claims. A JWT is signed but not encrypted. The payload is base64, readable by anyone who has the token.
- Store the signing secret outside the binary. See the secrets section below.
Session cookies for browser apps
For browser-facing APIs, sessions in a signed/encrypted cookie are usually better than JWTs in localStorage. The standard practice is gorilla/sessions or alexedwards/scs, but the cookie attributes matter more than the library:
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
HttpOnly: true, // Not readable from JS — defeats XSS exfil.
Secure: true, // HTTPS only.
SameSite: http.SameSiteLaxMode, // Or Strict if you don't need cross-site nav.
MaxAge: int((24 * time.Hour).Seconds()),
})
HttpOnly + Secure + SameSite is the minimum bar. Without HttpOnly, an XSS bug becomes a full session takeover. Without Secure, a coffee-shop network can sniff the cookie. Without SameSite, you're CSRF-vulnerable on any state-changing endpoint that uses the cookie for auth.
Authorize on every request, not just authenticate
It's easy to write middleware that says "is this user logged in" and forget that "is this user allowed to touch this resource" is a separate check. The classic broken pattern:
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
user := userFromCtx(r.Context()) // Auth middleware put this here.
id := orderID(r)
order, err := h.repo.GetOrder(r.Context(), id)
if err != nil { /* ... */ }
writeJSON(w, order) // We never checked if this order belongs to `user`!
}
That's an IDOR (Insecure Direct Object Reference) waiting to happen. Any logged-in user can read any order by guessing the ID. The fix is small but absolutely necessary:
order, err := h.repo.GetOrderForUser(r.Context(), id, user.ID)
if errors.Is(err, sql.ErrNoRows) {
// Treat unauthorized as not-found so we don't leak existence.
http.NotFound(w, r)
return
}
Push the ownership check into the query (WHERE id = ? AND user_id = ?) so it can't be forgotten. The 404-instead-of-403 trick is a small but worthwhile detail. It stops an attacker from enumerating which IDs exist.

4. Secrets: never in the binary, never in the logs
Secrets are the failure mode that almost everyone has hit at least once. The mistake is rarely dramatic. It's not a hacker breaking in; it's an engineer copy-pasting an API key into a Slack channel or committing .env to git "just for this PR".
Read them from the environment, not from the code
The baseline is os.Getenv, and for almost everything that's enough:
type Config struct {
DBURL string
JWTSecret []byte
StripeKey string
Environment string
}
func Load() (*Config, error) {
cfg := &Config{
DBURL: mustEnv("DATABASE_URL"),
JWTSecret: []byte(mustEnv("JWT_SECRET")),
StripeKey: mustEnv("STRIPE_KEY"),
Environment: getEnv("APP_ENV", "production"),
}
if len(cfg.JWTSecret) < 32 {
return nil, errors.New("JWT_SECRET must be at least 32 bytes")
}
return cfg, nil
}
func mustEnv(k string) string {
v := os.Getenv(k)
if v == "" {
log.Fatalf("missing required env var: %s", k)
}
return v
}
Loading config at startup and failing fast if anything is missing means your process refuses to even start with a broken configuration. Much better than discovering it at request 10,000.
For local development, .env files are fine if you load them through github.com/joho/godotenv only in dev mode, and .env is in .gitignore. Don't ship a .env.example with real values "as a placeholder". Copy a fake one and document it.
Use a real secrets store in production
For anything important, your secrets should live in a managed store, not in environment variables that anyone with kubectl exec can read:
- AWS Secrets Manager / SSM Parameter Store
- HashiCorp Vault
- GCP Secret Manager
- Azure Key Vault
The pattern is the same for all of them: pull the secret at startup (or on a schedule), cache it in memory, and use the cloud provider's IAM to control who can read it. The official AWS SDK call is straightforward:
import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)
func fetchSecret(ctx context.Context, name string) (string, error) {
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return "", err
}
client := secretsmanager.NewFromConfig(cfg)
out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: &name,
})
if err != nil {
return "", err
}
return *out.SecretString, nil
}
The big win isn't the SDK. It's that rotation becomes a one-command operation and access is audited.
Don't log secrets, ever
This one is harder than it sounds because the leak is rarely a direct log.Println(apiKey). It's usually one of these:
// Logs the whole request including Authorization header.
log.Printf("incoming request: %+v\n", r)
// Logs the whole struct, including the Token field.
log.Printf("config: %+v\n", cfg)
// Sends every panic — including secrets in the stack — to a third party.
sentry.CaptureException(fmt.Errorf("got: %+v", req))
The fix is a Redact() method, or a typed wrapper that knows how to print itself safely:
type Secret string
func (s Secret) String() string { return "[REDACTED]" }
func (s Secret) GoString() string { return "[REDACTED]" }
func (s Secret) MarshalJSON() ([]byte, error) {
return []byte(`"[REDACTED]"`), nil
}
func (s Secret) Reveal() string { return string(s) }
Wrap every secret value in Secret. Now even a lazy log.Printf("%+v", cfg) is safe. The only way to get the raw value is to call .Reveal(), which makes the leak grep-able in code review.
Rotate, and assume something will leak anyway
No matter how carefully you handle them, secrets leak. A laptop gets stolen, a backup tape gets misplaced, a developer pastes the staging URL with the password in the query string into a Stack Overflow question. Two practical defenses:
- Rotation. Every long-lived secret should have a documented rotation procedure that someone runs at least once a year, ideally automatically. If you've never rotated a secret, you don't know if you can.
- Scoped credentials. A read-only database user, an AWS role with
s3:GetObjecton one bucket, a Stripe restricted API key with publishable-only permissions. The blast radius of a leaked credential is bounded by what it can do.
5. Dependency scanning: assume your dependencies are out to get you
The modern Go service has a go.mod with 30 direct dependencies and a go.sum with 250 transitive ones. Most of those are fine. Some of them, statistically, are not. The dependency-supply-chain problem is the same in Go as it is anywhere else. The only difference is that Go has unusually good tooling for it.
Run govulncheck in CI
govulncheck is the official Go vulnerability scanner, maintained by the Go security team. It cross-references your dependency tree against the Go vulnerability database, and crucially, it only flags issues for code paths your binary actually uses. That last part matters a lot: a CVE in a function you don't call is noise, and traditional scanners flood you with it.
go install golang.org/x/vuln/cmd/govulncheck@latest
#!/bin/bash
set -e
govulncheck ./...
In a typical GitHub Actions job:
name: security
on: [push, pull_request]
jobs:
vuln:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...
The job fails on any finding. Pin the Go version if you're running an older release. govulncheck itself runs on most versions, but its analysis is most accurate when paired with a recent toolchain.
Layer in a static-analysis security linter
govulncheck finds known CVEs. It doesn't tell you that you're using math/rand for a session token, or that your tls.Config has MinVersion: tls.VersionSSL30. For that you want gosec:
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -exclude=G104 ./...
gosec has roughly 40 rules: weak crypto, hardcoded credentials in source, SQL injection via string interpolation, world-writable file permissions, unsafe template execution, and so on. Some rules generate noise (G104 "errors unhandled" is the classic). Tune the exclusion list once and then leave it alone.
Pin and verify with the checksum database
Go has had this baked in since modules launched, but it's worth knowing the contract. Your go.sum file is a list of cryptographic hashes for every dependency, and go mod verify checks that the cached modules still match. Run it in CI:
go mod verify
When you upgrade dependencies (go get -u), Go fetches both the module and a signed proof from sum.golang.org that no one has tampered with it. If you're working in an environment where you can't reach the public checksum database, you can mirror it or configure GONOSUMCHECK, but the default behavior is the safe one and you should leave it alone.
Keep a software bill of materials
If you ship a binary that someone else runs, they're going to ask you what's in it sooner or later. The cyclonedx-gomod tool will generate a CycloneDX SBOM directly from your go.mod:
go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest
cyclonedx-gomod app -licenses -output sbom.json
Keep that file alongside your release artifacts. When the next big "log4j-style" CVE drops, you can grep your SBOM to answer "are we vulnerable" in seconds instead of hours.
Watch transitive dependencies in particular
Most of your risk lives in the transitive layer: the dependencies of your dependencies. They get less scrutiny, they update less often, and they're where supply-chain attacks tend to land. A few habits that help:
- Read
go mod graphoccasionally. Just glance at it. If you see something surprising, follow up. - Be suspicious of dependencies on tiny utility modules (one-function packages from unknown maintainers). They're a common attack surface.
- Prefer the standard library when the standard library will do. The Go team's track record on security is excellent and there's no supply chain to worry about.
A note on what's missing from this list
This article isn't the security checklist, it's a Go-flavored one. There are entire categories I haven't covered: TLS configuration in real depth, CORS, CSRF tokens for non-API routes, rate limiting per user vs per IP, audit logging, observability for security events, container image hardening, the right way to do mTLS between services. All of those matter, all of those have good Go tooling, and any one of them could be its own article.
But if you walk away with the five basics (strict input validation, a clock on every boundary, real auth primitives used correctly, secrets that live outside your binary, and a CI step that fails on known vulns), you've moved your API from "I hope nobody notices" to a place where a security audit becomes a conversation about polish, not a list of things on fire. Which is, honestly, the difference between a Friday afternoon and a Monday morning.





