Speed without security is a liability. Here's what I enforce on every production Go codebase — not just preach about.
Go is fast, expressive, and increasingly the language of choice for backend systems, CLIs, and cloud-native infrastructure. But its simplicity can be deceptive. The language won't hold your hand when it comes to security — that responsibility falls entirely on you.
After working on several Go services in production, I've distilled my security checklist down to ten practices I reach for every single time. These aren't theoretical — each one has saved me (or a colleague) from a real vulnerability.
1. Input Validation — Validate at the Boundary
The rule: Never trust user input. Validate it as early as possible — at the edge of your system, not buried in business logic.
The most common mistake I see is validating data deep inside a service function, after it has already been passed around. By the time you get there, the damage from bad input may already be done.
Use go-playground/validator for struct-level validation with declarative tags:
type CreateUserReq struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"min=0,max=120"`
Role string `json:"role" validate:"oneof=admin user viewer"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := h.validator.Struct(req); err != nil {
http.Error(w, "validation failed", http.StatusBadRequest)
return
}
// ...
}
Key principle: Validate → Sanitize → Process. In that order, every time.
2. SQL Injection — Parameterized Queries, Always
The rule: Never concatenate user input into SQL strings. Full stop.
SQL injection remains one of the most exploited vulnerability classes in web applications, and it's entirely preventable. Go's database/sql package makes it easy to do the right thing — but you have to choose it deliberately.
Direct string interpolation (bad practice):
userID := r.URL.Query().Get("id")
rows, err := db.Query("SELECT * FROM users WHERE id = " + userID)
Best Practices:
// always use parameterized queries
rows, err := db.Query("SELECT * FROM users WHERE id = ?", userID)
// named parameters (postgres style)
rows, err := db.Query("SELECT * FROM users WHERE id = $1", userID)
If you're using an ORM like GORM, understand the SQL it generates. ORMs can still produce injectable queries when you use raw query methods without care:
Raw query with string formatting — still injectable (bad practice):
db.Raw(fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name))
GORM parameterized (best practice):
db.Raw("SELECT * FROM users WHERE name = ?", name)
3. Command Injection — Separate Args, Never Shell Strings
The rule: When using exec.Command, pass arguments as separate values — never interpolate user input into a shell command string.
This is a subtle but critical distinction. When you pass a single shell string (e.g., via sh -c), the shell interprets the entire string — including any special characters a user might inject.
Shell injection risk (very bad practice):
userInput := r.FormValue("host")
cmd := exec.Command("sh", "-c", "ping -c 1 " + userInput)
if userInput localhost; rm -rf /, you're done.
Args are passed directly to the binary — no shell interpretation (best practice):
cmd := exec.Command("ping", "-c", "1", userInput)
out, err := cmd.Output()
When you pass args as separate parameters, exec.Command bypasses the shell entirely. The binary receives the arguments raw — no interpretation, no injection.
Bonus: Validate and allowlist inputs before they even reach exec.Command:
allowed := map[string]bool{"localhost": true, "8.8.8.8": true}
if !allowed[userInput] {
return fmt.Errorf("host not permitted: %s", userInput)
}
4. Unsafe Deserialization — Type Everything
The rule: Deserialize into typed structs. Validate the result. Never unmarshal into interface{} from untrusted sources.
json.Unmarshal into interface{} gives you a map[string]interface{} that you then type-assert at runtime. This bypasses Go's type system, makes validation awkward, and opens the door for unexpected data shapes.
Unsafe — you're flying blind (bad practice):
var payload interface{}
json.Unmarshal(data, &payload)
m := payload.(map[string]interface{})
userID := m["user_id"].(float64) // panics if wrong type
Typed and validated (best practice):
type Payload struct {
UserID int `json:"user_id"`
Action string `json:"action"`
}
var p Payload
if err := json.Unmarshal(data, &p); err != nil {
return fmt.Errorf("malformed payload: %w", err)
}
// then validate the struct
if err := validate.Struct(p); err != nil {
return fmt.Errorf("invalid payload: %w", err)
}
The same principle applies to encoding/gob and any other deserialization format. If the data comes from outside your trust boundary, it needs a type and a validator.
5. Secrets Management — Get Them Out of Your Code
The rule: Secrets don't belong in source code, .env files committed to git, environment variables as your only guard, or log output.
This one seems obvious, but I still see hardcoded API keys in Go repos — sometimes in constants, sometimes buried in config structs, sometimes in test files that "don't really matter."
Hardcoded — anyone with repo access has your secret (bad practice):
const stripeKey = "sk_live_abc123xyz"
Env vars are better, but not enough on their own (bad practice):
key := os.Getenv("STRIPE_KEY") // still appears in process env, CI logs, etc.
Use a secrets manager (best practice):
import "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
func getStripeKey(ctx context.Context, client *secretsmanager.Client) (string, error) {
result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String("prod/stripe/api-key"),
})
if err != nil {
return "", fmt.Errorf("fetch secret: %w", err)
}
return *result.SecretString, nil
}
My production checklist for secrets:
- Use AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault
- Rotate secrets on a schedule and on every suspected exposure
- Use short-lived credentials wherever possible (IAM roles, Workload Identity)
- Audit secret access logs — know who fetched what and when
- Add a pre-commit hook or CI check (like
gitleaks) to catch accidental commits
6. Error Handling — Wrap Internally, Sanitize Externally
The rule: Wrap errors with context for internal logging. Return generic messages to API consumers. Never ignore errors.
Go's explicit error handling is one of its greatest strengths — but only if you use it. Two failure modes are common:
Failure mode 1: Ignoring errors
result, _ := dangerousOperation() // silent failure — you'll never know something went wrong
Failure mode 2: Leaking internal errors to users
http.Error(w, err.Error(), http.StatusInternalServerError) // raw error exposed — reveals internals to the caller
Output: pq: duplicate key value violates unique constraint "users_email_key"
The right approach:
- Wrap with context for logging
result, err := db.CreateUser(ctx, req)
if err != nil {
log.Printf("create user failed: %v", err) // full error internally
http.Error(w, "could not create user", http.StatusInternalServerError) // generic externally
return
}
- Use fmt.Errorf with %w to preserve the error chain
func (s *Service) CreateUser(ctx context.Context, req CreateUserReq) (*User, error) {
user, err := s.repo.Insert(ctx, req)
if err != nil {
return nil, fmt.Errorf("user service: create: %w", err)
}
return user, nil
}
The %w verb lets callers use errors.Is and errors.As to inspect the chain — without exposing it externally.
7. Context Handling — Always Propagate, Always Cancel
The rule: Every function that does I/O should accept a context.Context. Always set a deadline or timeout. Always call the cancel function.
A goroutine or an I/O operation with no context and no deadline is a resource leak waiting to happen. If the upstream caller disconnects, your goroutine keeps running — holding connections, consuming memory, potentially mutating state.
No context — runaway operations, no cancellation (bad practice):
func fetchUser(id int) (*User, error) {
return db.Query("SELECT * FROM users WHERE id = ?", id)
}
Context-aware with timeout (best practice):
func fetchUser(ctx context.Context, id int) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // always defer cancel, even if ctx expires naturally
row := db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("fetch user %d: %w", id, err)
}
return &u, nil
}
Propagate context through the entire call chain — handler → service → repository → database. If any level breaks the chain, you lose cancellation for everything downstream.
HTTP handler — context comes from the request:
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // already has deadline from server config
user, err := h.service.FetchUser(ctx, userID)
// ...
}
8. Race Conditions — Detect Early, Protect Always
The rule: Run Go's race detector during development and CI. Protect all shared mutable state with explicit synchronization.
Go's concurrency model is powerful, but power without discipline leads to race conditions — and data races are undefined behavior. They can corrupt state silently, cause intermittent crashes, or produce security-relevant inconsistencies.
Enable the race detector:
go test -race ./...
go build -race ./cmd/server
Add -race to your CI pipeline. It has some overhead, but it's the cheapest way to catch races before they hit production.
Protecting shared state:
Concurrent writes to a map without a lock — data race (bad practice):
var cache = map[string]string{}
func set(key, val string) {
cache[key] = val // race if called from multiple goroutines
}
Best practices:
// sync.Mutex
var (
mu sync.Mutex
cache = map[string]string{}
)
func set(key, val string) {
mu.Lock()
defer mu.Unlock()
cache[key] = val
}
// sync.Map for concurrent-safe access patterns
var cache sync.Map
func set(key, val string) {
cache.Store(key, val)
}
// atomic for simple counters
var requestCount int64
func increment() {
atomic.AddInt64(&requestCount, 1)
}
Pick the right tool: sync.Mutex for general cases, sync/atomic for simple counters, sync.Map for concurrent read-heavy maps, and channels when you're coordinating work between goroutines.
9. Dependency Hygiene — Your Code Is Only as Secure as Your Deps
The rule: Audit your dependency tree. Scan for known vulnerabilities. Pin versions. Don't skip updates.
Supply chain attacks are on the rise. A compromised or vulnerable transitive dependency can undermine everything else in this list.
Tools I use:
# google's official Go vulnerability scanner
govulncheck ./...
# check your go.sum for known bad hashes
go mod verify
# list all dependencies (review these)
go list -m all
My dependency workflow:
- Before adding a new dependency — check the repo's activity, maintainers, and star count. Prefer dependencies with narrow scope.
- After adding — run
govulncheckand review thego.sumdiff. - On a schedule — run
govulncheckin CI on every pull request and as a weekly cron job. - When upgrading — read the changelog. Don't blindly accept major version bumps.
# add to your CI pipeline
- name: Vulnerability scan
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
The go.sum file is your lockfile — treat it as a security artifact. Verify it in CI with go mod verify.
10. Least-Privilege in HTTP Handlers — Auth Close to the Resource
The rule: Apply authorization checks as close to the handler as possible. Never rely solely on a gateway or a single middleware layer at the top of your router.
Defense in depth means you don't trust a single checkpoint. Gateways fail, middleware gets misconfigured, routes get added without proper parent middleware. Checking authorization at the handler level is your last line of defense.
Single auth check at the top — one misconfiguration exposes everything (bad practice):
router.Use(RequireAuth)
router.Post("/admin/delete-user", adminHandler) // assumes middleware ran
Role-specific middleware per route group (best practice):
adminRouter := router.Group("/admin")
adminRouter.Use(RequireAuth, RequireRole("admin"))
adminRouter.Post("/delete-user", deleteUserHandler)
Or verify inside the handler itself (belt-and-suspenders):
func deleteUserHandler(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.FromContext(r.Context())
if !ok || claims.Role != "admin" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ...
}
Principles for least-privilege handlers:
- Group routes by required permission and apply middleware to the group
- Double-check roles inside sensitive handlers — middleware can be bypassed if a route is accidentally registered outside the group
- Use Go's
contextto pass auth claims from middleware to handlers — don't re-fetch from DB in every handler - Log all authorization failures — they're often the first signal of an attack
Putting It All Together
These ten practices aren't independent checklists — they reinforce each other. Proper input validation reduces your SQL injection surface. Correct error handling prevents you from leaking information about your auth or database structure. Context propagation ensures that even if something goes wrong, your system fails cleanly.
Here's a quick reference for your next code review:
| # | Practice | Key Tool / Approach |
|---|---|---|
| 1 | Input Validation | go-playground/validator |
| 2 | SQL Injection | Parameterized queries |
| 3 | Command Injection | Separate exec.Command args |
| 4 | Unsafe Deserialization | Typed structs + validate |
| 5 | Secrets | Secrets Manager + gitleaks |
| 6 | Error Handling | fmt.Errorf + %w, sanitize output |
| 7 | Context Handling | context.WithTimeout + defer cancel |
| 8 | Race Conditions | -race flag + sync package |
| 9 | Dependency Hygiene | govulncheck in CI |
| 10 | Least-Privilege | Per-route middleware + handler checks |
Security in Go isn't about using a magic framework — it's about disciplined habits applied consistently. The language gives you the tools. These practices are how you use them well.