homeprojectsblogabout
  • github

  • email

  • x / twitter

  • linkedin

  • personal docs

  • rss

© 2026 Yehezkiel Wiradhika

10 Secure Go Coding Practices

8/31/2026

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:

  1. 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
}
  1. 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:

  1. Before adding a new dependency — check the repo's activity, maintainers, and star count. Prefer dependencies with narrow scope.
  2. After adding — run govulncheck and review the go.sum diff.
  3. On a schedule — run govulncheck in CI on every pull request and as a weekly cron job.
  4. 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 context to 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:

#PracticeKey Tool / Approach
1Input Validationgo-playground/validator
2SQL InjectionParameterized queries
3Command InjectionSeparate exec.Command args
4Unsafe DeserializationTyped structs + validate
5SecretsSecrets Manager + gitleaks
6Error Handlingfmt.Errorf + %w, sanitize output
7Context Handlingcontext.WithTimeout + defer cancel
8Race Conditions-race flag + sync package
9Dependency Hygienegovulncheck in CI
10Least-PrivilegePer-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.

On this page