Why Your Go Config Loader Should Refuse to Start on a Missing Secret
Here is a line that looks harmless: key := os.Getenv("JWT_KEY"). If the variable is unset, you get an empty string, no error, and a service that starts perfectly happily. It then signs tokens with a zero-length key.
The worst part is that nothing breaks. Health checks pass, logins work, and the first sign of trouble is someone else minting valid tokens. A config loader should fail loudly at startup instead.
An empty key is a valid key to the standard library
Go's crypto/hmac will, in the normal (non-FIPS) mode, accept a key of any length, including none. An HMAC over an empty key is a real, computable value, and anyone who guesses your key is empty can compute it too.
The same goes for a database password, a webhook secret, an API token. Empty strings flow through as if they were configuration. The type system has no idea that "" is not a secret.
Unset and empty are different failures
os.Getenv collapses two situations into one. os.LookupEnv tells them apart with a second return value, and you usually want to treat both as errors, but with different messages. "Not set" usually means a deployment typo; "set but empty" usually means a templating step that interpolated nothing.
- Unset: the variable name is wrong, or the unit file never provided it.
- Empty: a secrets template rendered a blank, which is much harder to spot in a diff.
- Too short: someone pasted "changeme" or a placeholder.
A loader that checks all of it
This one reads each secret from NAME or, if present, from the file named by NAME_FILE. The file form matters because it works with systemd credentials and container secret mounts, and keeps the value out of the environment (which has its own problems).
package config
import (
"bytes"
"errors"
"fmt"
"os"
)
type Config struct {
Addr string
JWTKey []byte
DBPass []byte
}
func secret(name string, minLen int) ([]byte, error) {
var v []byte
if path, ok := os.LookupEnv(name + "_FILE"); ok {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("%s_FILE: %w", name, err)
}
v = bytes.TrimRight(b, "\r\n")
} else if s, ok := os.LookupEnv(name); ok {
v = []byte(s)
} else {
return nil, fmt.Errorf("%s: not set (and no %s_FILE)", name, name)
}
if len(v) == 0 {
return nil, fmt.Errorf("%s: set but empty", name)
}
if len(v) < minLen {
return nil, fmt.Errorf("%s: %d bytes, need at least %d", name, len(v), minLen)
}
return v, nil
}
func Load() (Config, error) {
var errs []error
c := Config{Addr: ":8080"}
if a, ok := os.LookupEnv("ADDR"); ok && a != "" {
c.Addr = a
}
var err error
if c.JWTKey, err = secret("JWT_KEY", 32); err != nil {
errs = append(errs, err)
}
if c.DBPass, err = secret("DB_PASS", 1); err != nil {
errs = append(errs, err)
}
if err := errors.Join(errs...); err != nil {
return Config{}, fmt.Errorf("invalid configuration: %w", err)
}
return c, nil
}
Report every problem at once
Notice the loader collects errors with errors.Join rather than returning on the first. Nothing is more irritating than fixing one missing variable, redeploying, and discovering the next one on the following restart.
The caller is then one short block in main:
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Doing it in main before any listener opens means an orchestrator sees a crash loop immediately, rather than a healthy-looking pod holding a useless key.
Never put the value in the message
Every error above names the variable and the length, never the contents. A "too short" message that echoes the secret ends up in your log aggregator, which is exactly where you did not want it. Length and name are enough to fix the problem.
Quick detour, because it bites people: os.ReadFile on a secret file usually brings a trailing newline with it, since editors and echo add one. That is why the loader trims \r\n. Without it, a key that works from the environment fails from the file, and the mismatch is maddening to find.
Test the failure, not the happy path
The happy path is exercised every time the service runs. The failure paths only run on a bad day, so test them. t.Setenv restores the environment afterwards, which keeps these tests independent.
func TestLoadRejectsEmptyAndShort(t *testing.T) {
t.Setenv("JWT_KEY", "")
t.Setenv("DB_PASS", "x")
if _, err := Load(); err == nil {
t.Fatal("expected an error for an empty JWT_KEY")
}
t.Setenv("JWT_KEY", "short")
if _, err := Load(); err == nil {
t.Fatal("expected an error for a short JWT_KEY")
}
}
One limit worth knowing: a length check only catches lazy values, not weak ones. "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" passes. If the secret is meant to be random, generate it with a tool and let the loader reject anything obviously not that, rather than trying to score entropy in config code.
A service that will not start is annoying for about thirty seconds. A service that starts without its secret can be annoying for much longer.