Most Go services ship with secrets spread across environment variables, config files, and the occasional hardcoded constant nobody remembers is there. That's manageable until you need to rotate a database password without restarting the service, or answer the question: "who accessed the production API key last Tuesday?" HashiCorp Vault solves both problems, and integrating it cleanly with Go is less work than most tutorials imply.
Why environment variables are not enough
Environment variables are convenient, not secure. They appear in process listings, get dumped in crash reports, and are inherited by every subprocess your service spawns. When a developer accidentally logs os.Environ() during debugging, every secret in the process goes to stdout — and probably to your centralized log platform.
More critically, env vars are static. Once set at startup, they don't rotate. If a credential leaks, your only option is to update the value and restart every affected service. In a microservices environment with dozens of instances, that's a painful and error-prone operation.
Vault offers three capabilities that env vars fundamentally can't provide:
- Dynamic secrets — Vault generates short-lived credentials on demand and revokes them automatically when the lease expires
- Fine-grained access policies — each service gets a token scoped to exactly the secrets it needs, nothing more
- Audit logging — every read, write, and login is recorded with timestamps and identities
Setting up Vault with AppRole authentication
For local development, Vault's dev mode is the fastest path:
vault server -dev -dev-root-token-id="dev-root-token"
export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='dev-root-token'
# Store a test secret in KV v2
vault kv put secret/myapp/db password="s3cr3t" user="appuser"
vault kv put secret/myapp/api key="sk-prod-abc123"
For production, avoid static Vault tokens entirely. Use AppRole: your service authenticates with a role ID (static, safe to embed in deployment config) and a secret ID (short-lived, injected at runtime). The server exchanges those for a scoped token.
# Enable AppRole and create a role
vault auth enable approle
vault policy write myapp-policy - <<EOF
path "secret/data/myapp/*" {
capabilities = ["read"]
}
EOF
vault write auth/approle/role/myapp \\
token_ttl=1h \\
token_max_ttl=4h \\
policies=myapp-policy
# Fetch the role ID — static, store it in CI config
vault read -field=role_id auth/approle/role/myapp/role-id
# Generate a secret ID — rotate this on each deploy
vault write -field=secret_id -f auth/approle/role/myapp/secret-id
The separation matters: the role ID is like a username and can live in your Kubernetes deployment manifest. The secret ID is like a one-time password and should be injected by your CI system at pod startup, then discarded.
Reading secrets in Go with the Vault SDK
The github.com/hashicorp/vault/api package handles authentication and HTTP calls. Below is a minimal client that logs in via AppRole and fetches secrets from KV v2:
package main
import (
"context"
"fmt"
"log"
"os"
"sync"
vault "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/approle"
)
type VaultClient struct {
client *vault.Client
mu sync.RWMutex
cache map[string]map[string]interface{}
}
func NewVaultClient(addr, roleID, secretID string) (*VaultClient, *vault.Secret, error) {
config := vault.DefaultConfig()
config.Address = addr
client, err := vault.NewClient(config)
if err != nil {
return nil, nil, fmt.Errorf("vault client init: %w", err)
}
appRoleAuth, err := auth.NewAppRoleAuth(
roleID,
&auth.SecretID{FromString: secretID},
)
if err != nil {
return nil, nil, fmt.Errorf("approle auth init: %w", err)
}
authInfo, err := client.Auth().Login(context.Background(), appRoleAuth)
if err != nil {
return nil, nil, fmt.Errorf("vault login: %w", err)
}
if authInfo == nil {
return nil, nil, fmt.Errorf("no auth info returned from vault")
}
vc := &VaultClient{
client: client,
cache: make(map[string]map[string]interface{}),
}
return vc, authInfo, nil
}
func (v *VaultClient) GetSecret(ctx context.Context, path string) (map[string]interface{}, error) {
v.mu.RLock()
if cached, ok := v.cache[path]; ok {
v.mu.RUnlock()
return cached, nil
}
v.mu.RUnlock()
secret, err := v.client.KVv2("secret").Get(ctx, path)
if err != nil {
return nil, fmt.Errorf("get secret %s: %w", path, err)
}
if secret == nil || secret.Data == nil {
return nil, fmt.Errorf("secret %s not found or empty", path)
}
v.mu.Lock()
v.cache[path] = secret.Data
v.mu.Unlock()
return secret.Data, nil
}
func main() {
vc, authInfo, err := NewVaultClient(
os.Getenv("VAULT_ADDR"),
os.Getenv("VAULT_ROLE_ID"),
os.Getenv("VAULT_SECRET_ID"),
)
if err != nil {
log.Fatalf("vault setup: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go renewToken(ctx, vc.client, authInfo)
data, err := vc.GetSecret(ctx, "myapp/db")
if err != nil {
log.Fatalf("fetch secret: %v", err)
}
fmt.Printf("connecting as user: %s\n", data["user"])
}
The cache layer prevents Vault from becoming a hot path on every request — secrets are read once and held in memory. For dynamic secrets with short TTLs you'll want to invalidate the cache before the lease expires.
Handling token renewal without restarts
Vault tokens expire. If your service runs longer than the token's max TTL without renewal, calls start returning 403s. The SDK's LifetimeWatcher handles this cleanly:
func renewToken(ctx context.Context, client *vault.Client, authInfo *vault.Secret) {
watcher, err := client.NewLifetimeWatcher(&vault.LifetimeWatcherInput{
Secret: authInfo,
Increment: 3600,
})
if err != nil {
log.Printf("token watcher init failed: %v", err)
return
}
go watcher.Start()
defer watcher.Stop()
for {
select {
case err := <-watcher.DoneCh():
if err != nil {
log.Printf("token renewal failed, vault access lost: %v", err)
}
return
case renewal := <-watcher.RenewCh():
log.Printf("vault token renewed, TTL: %ds",
renewal.Secret.Auth.LeaseDuration)
case <-ctx.Done():
return
}
}
}
When DoneCh() fires, the token cannot be renewed and your service must re-authenticate. Wire the renewal goroutine to your application's health check so Kubernetes liveness probes can detect and recover from a Vault outage before it causes user-visible failures.
Structuring configuration in a production Go service
Keep Vault calls out of your hot path. Read all required secrets at startup into a typed struct, then use a read-write mutex for safe concurrent access and in-place hot-reloading:
type AppConfig struct {
mu sync.RWMutex
DBUser string
DBPass string
APIKey string
}
func LoadConfig(ctx context.Context, vc *VaultClient) (*AppConfig, error) {
dbData, err := vc.GetSecret(ctx, "myapp/db")
if err != nil {
return nil, fmt.Errorf("load db config: %w", err)
}
apiData, err := vc.GetSecret(ctx, "myapp/api")
if err != nil {
return nil, fmt.Errorf("load api config: %w", err)
}
return &AppConfig{
DBUser: dbData["user"].(string),
DBPass: dbData["password"].(string),
APIKey: apiData["key"].(string),
}, nil
}
func (c *AppConfig) Credentials() (string, string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.DBUser, c.DBPass
}
func (c *AppConfig) Reload(ctx context.Context, vc *VaultClient) error {
fresh, err := LoadConfig(ctx, vc)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.DBUser = fresh.DBUser
c.DBPass = fresh.DBPass
c.APIKey = fresh.APIKey
return nil
}
This separates "when to fetch" from "how to use", and makes testing straightforward — in unit tests, populate the struct directly without touching Vault at all.
For a complete checklist of Vault hardening steps — including policy templates, audit backend configuration, and AppRole bootstrap procedures — see our free security hardening checklists in PDF and Excel format.
The takeaway
Environment variables are a footgun for secrets at any scale beyond a side project. Vault gives you short-lived credentials, immutable audit trails, and automatic revocation — the three things that matter during post-incident forensics or an access review.
The Go SDK handles AppRole authentication and token renewal cleanly once you understand the flow. The pattern above — authenticate at startup, cache secrets in a typed struct, renew the token in a background goroutine — covers most production Go services without meaningful added complexity.
One thing most teams skip: write a runbook for Vault unavailability. A service that fails hard at boot when Vault is down, then retries from a local cache if connectivity drops mid-run, behaves predictably under failure. A service that silently falls back to stale secrets or skips auth checks is a different problem entirely — the kind that surfaces in breach post-mortems, not in health checks.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)