Right, let’s talk about secrets. You know, the things that, if they get out, turn your expensive cloud bill into someone else’s very expensive free crypto-mining rig. We’ve all seen the GitHub repo with AWS_ACCESS_KEY_ID="AKIAIMNOTTELLINGYOU" committed three years ago and never rotated. Don’t be that person. Managing secrets is arguably more about discipline than technology, but since this is a Go book, we’ll focus on how the technology can save you from yourself.

The absolute bare minimum, the “better than nothing” standard, is using environment variables. It’s not the most secure method—the secrets are sitting right there in plain text in your process memory, after all—but it’s vastly superior to hardcoding them. It keeps your secrets out of your version control, which is 90% of the battle.

The Bare Minimum: Environment Variables

Go makes grabbing environment variables stupidly simple with os.Getenv. The problem is that this function is a bit… optimistic. It returns an empty string if the variable isn’t set, which is the same value it returns if the variable is set but just empty. This is a fantastic way to have your application fail in a confusing, cryptic manner at runtime.

package main

import (
    "fmt"
    "os"
)

func main() {
    // Don't do this. It's bad and you should feel bad.
    dbPassword := os.Getenv("DB_PASSWORD")
    if dbPassword == "" {
        // But is it empty because it's unset? Or set to empty?
        // You have no idea. Panic accordingly.
        panic("DB_PASSWORD environment variable is required")
    }
    // ... connect to database
}

A much more robust approach is to use os.LookupEnv. It returns the value and a boolean indicating if the variable was actually found. This at least lets you distinguish between “not set” and “set to empty string,” which is a crucial distinction for error reporting.

func main() {
    // Do this instead. It's slightly less likely to ruin your afternoon.
    dbPassword, ok := os.LookupEnv("DB_PASSWORD")
    if !ok {
        panic("DB_PASSWORD environment variable is not set")
    }
    if dbPassword == "" {
        panic("DB_PASSWORD is set but is empty, which is also unhelpful")
    }
    fmt.Println("We're in! (Hopefully)")
}

The best practice here is to validate and load your configuration—including secrets—at application startup. The github.com/spf13/viper library is fantastic for this, as it can pull from environment variables, config files, and more, giving you a single, validated config struct. But remember, the secret is still sitting in a string in your program’s memory. For local development, this is often acceptable. For production? We can do better.

Leveling Up: The Vault of the Pros

When you graduate from “my side project” to “something a company depends on,” you need a real secrets management tool. HashiCorp Vault is the industry standard for a reason. It’s incredibly powerful. Instead of a static secret sitting on your disk, Vault can generate short-lived, dynamic secrets on-demand, automatically rotate them, and control access with fine-grained policies.

The official Vault API client for Go (github.com/hashicorp/vault/api) is excellent, but using it directly means writing a fair amount of boilerplate for authentication, renewal, and secret leasing. Let’s look at a basic example of reading a static secret from a KV v2 store.

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/hashicorp/vault/api"
)

func main() {
    // 1. Configure the Vault client. Often pulls VAULT_ADDR and VAULT_TOKEN from env.
    config := api.DefaultConfig()
    client, err := api.NewClient(config)
    if err != nil {
        log.Fatalf("Failed to create Vault client: %v", err)
    }

    // 2. Read the secret from the KV v2 engine.
    // The mount path 'secret' is common for KV v2 in dev environments.
    secret, err := client.KVv2("secret").Get(context.Background(), "myapp/database")
    if err != nil {
        log.Fatalf("Failed to read secret: %v", err)
    }

    // 3. Extract the specific data we need.
    username, ok := secret.Data["username"].(string)
    if !ok {
        log.Fatal("Username not found or not a string")
    }
    password, ok := secret.Data["password"].(string)
    if !ok {
        log.Fatal("Password not found or not a string")
    }

    fmt.Printf("Connecting with user: %s\n", username)
    // ... use the username and password
}

The real magic, and the reason you use Vault, is with dynamic secrets. Need a database credential? You don’t pre-create a user. You ask Vault, which has a root-level database connection, to generate a brand new user and password with a 1-hour TTL just for you. Your app fetches this credential, uses it, and Vault automatically revokes it after the lease expires. This drastically reduces the blast radius of a secret being leaked.

The Crucial Pitfall: The Token Itself

See the catch-22? To get the database password from Vault, your application first needs to authenticate to Vault. How? Usually with… a token. And where do you put that token? Right back in an environment variable or a file on the disk. We’ve just moved the problem. This is the secret you have to guard with your life, as it’s the key to the entire kingdom.

This is where Vault’s various auth methods come in to break the cycle. On AWS, your Go application can use the IAM role of the EC2 instance it’s running on to authenticate to Vault (AWS auth method). In Kubernetes, it can use its service account token (Kubernetes auth method). This means the initial secret (the IAM role or K8s SA) is managed by the cloud platform, not you. Your application code never handles a long-lived Vault token. This is the way.

The bottom line: Use environment variables for development and to bootstrap the process. For any serious production workload, use a secrets manager like Vault with a cloud-native authentication method. Your future self, who won’t have to explain a security breach to the CEO, will thank you.