Skip to main content

Secure Coding Practices in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Secure coding is the practice of eliminating vulnerabilities during development rather than patching them afterward. Go’s standard library avoids many classic C vulnerabilities (buffer overflows, use-after-free), but application-level bugs — SQL injection, path traversal, information leakage — require deliberate prevention.

This guide covers the practical patterns that prevent the most common application security issues in Go services. For web-specific security (XSS, CSRF, headers) see Go web application security. For cryptography see Go cryptography.

Secrets: Never in Code

The first rule: secrets belong in environment variables or a secrets manager, never in source code:

// ❌ Hardcoded — will leak in git history forever
db, _ := sql.Open("postgres", "postgres://admin:SuperSecret@localhost/prod")

// ✅ From environment — rotatable without code changes
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if os.Getenv("DATABASE_URL") == "" {
    log.Fatal("DATABASE_URL not set")
}

Validate required secrets at startup — fail loudly rather than running in a broken state:

func loadSecrets() error {
    required := []string{"DATABASE_URL", "JWT_SECRET", "STRIPE_KEY"}
    for _, key := range required {
        if os.Getenv(key) == "" {
            return fmt.Errorf("required secret %s not set", key)
        }
    }
    // Minimum key length enforcement
    if len(os.Getenv("JWT_SECRET")) < 32 {
        return fmt.Errorf("JWT_SECRET must be at least 32 characters")
    }
    return nil
}

For production, use AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault — see Go configuration management.

Error Messages: Generic to Users, Detailed in Logs

Error messages returned to users must not reveal system internals. Error messages in logs must include enough context to diagnose the problem:

// ❌ Reveals too much — user enumeration, stack traces, SQL details
return fmt.Errorf("user [email protected] not found in database table users")

// ✅ Generic to client, detailed in logs
func authenticateUser(ctx context.Context, email, password string) (*User, error) {
    user, err := db.GetUserByEmail(ctx, email)
    if err != nil {
        // Log the detail internally
        slog.ErrorContext(ctx, "auth lookup failed",
            slog.String("email", email),
            slog.Any("error", err))
        // Return generic error to caller
        return nil, ErrInvalidCredentials
    }

    if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
        slog.WarnContext(ctx, "invalid password attempt",
            slog.String("email", email))
        // Same error regardless of which check failed — prevents enumeration
        return nil, ErrInvalidCredentials
    }
    return user, nil
}

var ErrInvalidCredentials = errors.New("invalid credentials")

Returning ErrInvalidCredentials for both “user not found” and “wrong password” is intentional — different messages would let attackers enumerate valid email addresses.

Path Traversal Prevention

Never join user-provided paths directly with a base directory without validation. ../../../etc/passwd can escape any prefix:

// ❌ Path traversal — user can pass "../../../etc/passwd"
func serveFile(baseDir, userPath string) ([]byte, error) {
    return os.ReadFile(filepath.Join(baseDir, userPath))
}

// ✅ Validate the resolved path stays inside the base directory
func serveFileSafe(baseDir, userPath string) ([]byte, error) {
    // Clean the user path first
    clean := filepath.Clean(userPath)

    // Resolve the full absolute path
    full := filepath.Join(baseDir, clean)
    abs, err := filepath.Abs(full)
    if err != nil { return nil, err }

    absBase, err := filepath.Abs(baseDir)
    if err != nil { return nil, err }

    // Ensure the resolved path is still inside baseDir
    if !strings.HasPrefix(abs, absBase+string(os.PathSeparator)) {
        return nil, fmt.Errorf("path traversal detected: %q", userPath)
    }

    return os.ReadFile(abs)
}

The strings.HasPrefix(abs, absBase+string(os.PathSeparator)) check — note the trailing separator — prevents /base/dirother from being accepted as inside /base/dir.

Secure File Permissions

When creating files, use restrictive permissions by default:

// ❌ World-readable — anyone on the system can read this
os.WriteFile("config.json", data, 0666)

// ✅ Owner-only read/write for sensitive files
os.WriteFile("config.json", data, 0600)

// ✅ Group-readable for shared logs
os.WriteFile("/var/log/app.log", data, 0640)

// ✅ Executable and readable for scripts
os.WriteFile("deploy.sh", script, 0755)

The rule of thumb: start with the minimum permission needed and add only what’s required. Config files with credentials: 0600. Log files: 0640. Directories: 0750 (owner full, group read+execute).

Constant-Time Comparisons

Comparing secrets with == or bytes.Equal short-circuits on the first different byte, leaking timing information. An attacker can determine correct bytes one at a time by measuring response times. Use hmac.Equal or subtle.ConstantTimeCompare:

import (
    "crypto/hmac"
    "crypto/subtle"
)

// ❌ Timing-vulnerable comparison
if secret == expectedSecret { ... }
if bytes.Equal(token, expectedToken) { ... }

// ✅ Constant-time comparison
if hmac.Equal([]byte(secret), []byte(expectedSecret)) { ... }
if subtle.ConstantTimeCompare(token, expectedToken) == 1 { ... }

This matters for: session tokens, CSRF tokens, API keys, HMAC signatures. It doesn’t matter for: usernames, email addresses, or other non-secret data.

Safe Integer Arithmetic

Go doesn’t panic on integer overflow — it wraps silently:

var x int8 = 127
x++  // -128, not 128

// For security-sensitive calculations (e.g., sizes, offsets)
func safeAdd(a, b int) (int, error) {
    if b > 0 && a > math.MaxInt-b {
        return 0, fmt.Errorf("integer overflow: %d + %d", a, b)
    }
    if b < 0 && a < math.MinInt-b {
        return 0, fmt.Errorf("integer underflow: %d + %d", a, b)
    }
    return a + b, nil
}

// When computing buffer sizes or slice indices from user input:
size, err := safeAdd(userSuppliedLen, overhead)
if err != nil {
    return fmt.Errorf("invalid size: %w", err)
}
buf := make([]byte, size)

Integer overflow vulnerabilities are less common in Go than C, but they’re still real in code that uses int8/int16/int32 for sizes or offsets.

Dependency Security

Third-party packages are attack surface. Audit them regularly:

# Check your dependency tree against the Go vulnerability database
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

# Pin exact versions in go.mod — don't use @latest in production
go get github.com/some/[email protected]  # specific version
go get github.com/some/library@latest  # avoid in production

# Review what each dependency actually does before adding it
go mod graph | grep somedep

govulncheck only reports vulnerabilities in code paths your binary actually calls — very low false positive rate. Add it to CI:

- name: Security audit
  run: govulncheck ./...

Also run gosec for static analysis of Go-specific security patterns:

go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...

Secure Logging

Never log sensitive data — if logs are compromised, so is everything in them:

// ❌ Leaks credentials into logs
slog.Info("database connected", slog.String("dsn", os.Getenv("DATABASE_URL")))
slog.Info("user login", slog.String("password", req.Password))

// ✅ Log the fact, not the secret
slog.Info("database connected", slog.String("host", dbHost))
slog.Info("user login attempt", slog.String("email", req.Email))
// Never log the password — it never needs to be in a log

// Mask sensitive fields in request logging
func maskDSN(dsn string) string {
    u, err := url.Parse(dsn)
    if err != nil { return "[invalid-dsn]" }
    if u.User != nil {
        u.User = url.UserPassword(u.User.Username(), "***")
    }
    return u.String()
}

Security Checklist

Before deploying any service:

  • No secrets in source code or git history
  • All secrets loaded from environment or secrets manager
  • Generic error messages to clients, detailed errors in logs
  • Parameterized queries for all database access (no string concatenation)
  • Input size limits enforced (http.MaxBytesReader, field length limits)
  • Path traversal checks on any user-provided file paths
  • Constant-time comparison for secrets and tokens
  • File permissions are as restrictive as possible
  • govulncheck ./... passes clean
  • go test -race ./... passes clean
  • TLS required in production (see Go HTTPS and TLS)
  • Security headers set (see Go web application security)
  • Authentication and authorization tested (see Go authentication)

Summary

  • Secrets in environment variables only — validate at startup, fail loudly on missing values
  • Return generic error messages to clients; log detailed context internally with the same error path
  • Path traversal: always validate that filepath.Abs(joined) starts with filepath.Abs(base)+"/"
  • Constant-time comparison (hmac.Equal, subtle.ConstantTimeCompare) for all token/secret comparisons
  • Integer overflow on int8/int16/int32 is silent in Go — check bounds for security-sensitive sizes
  • govulncheck ./... in CI — zero false positives, catches real CVEs in your call graph

Resources

Comments

👍 Was this article helpful?