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 withfilepath.Abs(base)+"/" - Constant-time comparison (
hmac.Equal,subtle.ConstantTimeCompare) for all token/secret comparisons - Integer overflow on
int8/int16/int32is silent in Go — check bounds for security-sensitive sizes govulncheck ./...in CI — zero false positives, catches real CVEs in your call graph
Comments