How to Handle Secrets and Sensitive Configuration in Go

Store secrets in environment variables or external vaults and access them via os.Getenv in Go, never hardcoding them in source files.

The secret in the commit

You push a commit. CI passes. The app deploys. Ten minutes later, your phone buzzes. The database password is sitting in plain text in the public GitHub repository. Or the app crashes in production because the config file that worked on your laptop doesn't exist on the server. Secrets are the minefield of deployment. Go does not hold your hand here. It gives you the tools, but you have to choose the safe path.

Keys, blueprints, and the process pocket

Think of a secret like a physical key to your house. You do not carve the key shape into the front door frame. You do not mail a photo of the key to your friends. You keep the key in your pocket, or you store it in a safe deposit box and only take it out when you need to open the door.

In software, the source code is the blueprint of the house. Blueprints go in version control. Keys do not. The "pocket" is the environment variables or a secrets manager. The "door" is your running process. The operating system passes secrets to the process at startup. The process reads them, uses them, and forgets them when it exits. The blueprint never touches the key.

Minimal example: os.Getenv

Here's the simplest way to load a secret: read it from the environment at startup.

package main

import (
	"fmt"
	"os"
)

func main() {
	// Read the secret from the environment.
	// os.Getenv returns an empty string if the key is missing.
	apiKey := os.Getenv("API_KEY")

	// Fail fast if the secret is missing.
	// A missing secret usually means a configuration error, not a runtime bug.
	if apiKey == "" {
		fmt.Fprintln(os.Stderr, "API_KEY is required")
		os.Exit(1)
	}

	// Use the secret.
	// Never log the full value. Print a masked version for debugging.
	fmt.Println("Connected with key:", apiKey[:4])
}

Check early. Fail loud.

Walkthrough: Empty string is not an error

When the program starts, the operating system passes a list of key-value pairs to the process. os.Getenv scans that list. If the key exists, it returns the value. If not, it returns an empty string.

Go does not panic. It does not throw an error. It silently returns an empty string. This is a design choice: environment variables are optional by default. Your code must decide if a missing variable is fatal. If you forget to check and pass an empty string to a database driver, the connection fails with a confusing error later. The driver might return pq: password authentication failed for user "admin" or dial tcp: lookup ... no such host. These errors leak information and obscure the root cause. Validate the secret before you use it.

Realistic example: Structs and LookupEnv

Real apps need structure. Use a config struct and os.LookupEnv to distinguish missing keys from empty values.

package main

import (
	"fmt"
	"os"
)

// Config holds application settings.
// Secrets live here alongside public config, but they never touch the source code.
type Config struct {
	DBHost     string
	DBUser     string
	DBPassword string
}

// LoadConfig reads settings from the environment.
// It returns a populated Config or an error if required fields are missing.
func LoadConfig() (*Config, error) {
	// os.LookupEnv returns the value and a boolean indicating presence.
	// This distinguishes between a missing key and an empty value.
	password, exists := os.LookupEnv("DB_PASSWORD")
	if !exists {
		return nil, fmt.Errorf("DB_PASSWORD must be set")
	}

	// Use os.Getenv for optional values with defaults.
	host := os.Getenv("DB_HOST")
	if host == "" {
		host = "localhost"
	}

	return &Config{
		DBHost:     host,
		DBUser:     os.Getenv("DB_USER"),
		DBPassword: password,
	}, nil
}

Structure config. Distinguish missing from empty.

Convention: if err != nil and public fields

The if err != nil pattern is verbose by design. It forces you to acknowledge the failure path. Do not hide errors behind panics in config loading. Return errors so the caller can decide how to handle the startup failure.

Public names start with a capital letter. Private names start lowercase. Your Config struct is public, but the fields containing secrets should be treated carefully. If you implement String() or MarshalJSON on Config, you risk dumping secrets into logs or API responses. Implement String() to return a redacted version, or avoid marshaling the config struct entirely.

// String returns a safe representation of the config.
// It redacts sensitive fields to prevent accidental leakage.
func (c *Config) String() string {
	return fmt.Sprintf("Config{Host: %s, User: %s, Password: ***}", c.DBHost, c.DBUser)
}

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		// Log the error, not the config.
		fmt.Fprintf(os.Stderr, "config error: %v\n", err)
		os.Exit(1)
	}

	// Use cfg.String() for logging.
	fmt.Println(cfg)
}

Logs are public. History is permanent. Treat secrets like cash.

Deep dive: Strings are immutable

Go strings are immutable. When you assign a secret to a string variable, the runtime allocates memory and copies the bytes. You cannot change those bytes later. The memory holds the secret until the garbage collector decides to reuse that block.

If your process crashes and generates a core dump, the secret is written to disk. If the OS swaps memory to disk, the secret travels with it. For most applications, this risk is acceptable. Environment variables and strings work fine. For high-security systems, avoid strings for secrets. Use []byte and explicitly zero the slice when done, or rely on a secrets manager that handles memory protection.

package main

import (
	"os"
)

func main() {
	// Read secret as bytes to allow zeroing.
	// This is only useful if you control the lifetime and zero the slice.
	password, exists := os.LookupEnv("DB_PASSWORD")
	if !exists {
		os.Exit(1)
	}

	// Convert to byte slice.
	// Note: strings are immutable, so this creates a copy.
	// The original string from LookupEnv still exists in memory.
	pwdBytes := []byte(password)

	// Use pwdBytes for authentication.
	// ...

	// Zero out the slice to remove the secret from memory.
	// This helps mitigate core dump exposure.
	for i := range pwdBytes {
		pwdBytes[i] = 0
	}
}

Strings are convenient. Secrets are volatile. Choose based on your threat model.

Testing: t.Setenv and parallel safety

Tests run in parallel. If one test calls os.Setenv, it changes the environment for every other test. This causes flaky failures. Go 1.17 introduced t.Setenv. This function sets the variable for the duration of the test and restores the original value automatically. Always use t.Setenv in tests. Never use os.Setenv directly in test functions.

package main

import (
	"testing"
)

func TestLoadConfig(t *testing.T) {
	// t.Setenv automatically restores the environment after the test.
	// This prevents leaking state to other tests.
	t.Setenv("DB_PASSWORD", "test-secret")
	t.Setenv("DB_HOST", "test-db")

	cfg, err := LoadConfig()
	if err != nil {
		t.Fatal(err)
	}

	if cfg.DBPassword != "test-secret" {
		t.Errorf("expected test-secret, got %s", cfg.DBPassword)
	}

	if cfg.DBHost != "test-db" {
		t.Errorf("expected test-db, got %s", cfg.DBHost)
	}
}

Parallel tests share the environment. Isolate changes with t.Setenv.

Pitfalls: Logs, history, and errors

Hardcoding secrets ends up in git history. Even if you delete the secret in a later commit, the history keeps it. Use .gitignore for local config files, but environment variables are safer for CI/CD pipelines.

Logging secrets is a breach. log.Printf("Connecting with password %s", password) prints the secret to stdout or stderr. In production, logs go to aggregators. Secrets in logs are accessible to anyone with log access. Mask secrets before logging. Use the String() method or manual redaction.

Error messages can leak secrets. If you return an error that includes the secret, it might end up in a log or a user-facing message. Wrap errors carefully. fmt.Errorf("connection failed: %w", err) preserves the chain but does not add the secret. Avoid fmt.Errorf("bad password %s", password).

The compiler rejects unused imports with imported and not used. It rejects undefined variables with undefined: pkg. These errors are helpful. They catch typos in variable names. They do not catch missing environment variables. You must write the checks.

Decision: Where to store secrets

Use environment variables when you need a simple, portable way to inject secrets into a process. Use a dedicated secrets manager like HashiCorp Vault or AWS Secrets Manager when you have many services, need automatic rotation, or require audit logs for access. Use a local configuration file when you are developing on a single machine and want quick iteration, but ensure the file is in .gitignore. Never hardcode secrets in source code, even for tests. Use mock values or test-specific environment variables instead.

Pick the tool that matches your threat model.

Where to go next