Handling Secrets
Keep passwords and tokens out of source code and out of logs by giving them a type that refuses to print itself.
func (s Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }A secret is a value that grants access: a database password, an API token, a signing key. Two rules cover almost every leak.
Never in the source code
A secret committed to version control stays in its history even after you delete the line. Anyone who can clone the repository, now or in five years, has it. Deleting the line does not undo this; the only fix is to rotate the secret, meaning replace it at the source so the old value stops working. Load secrets at startup instead, from an environment variable or from a file that the platform mounts. Kubernetes can mount a Secret as a file; AWS Secrets Manager hands one over through an API call.
Never in the logs
This is the harder half, because nobody logs a password on purpose. It leaks when a whole config struct is printed for debugging, when a database connection string appears inside an error, or when a request body is logged wholesale.
The reliable defence is a type. Define type Secret string and give it three methods: String(), which is what fmt uses for %v and %s; LogValue(), which log/slog uses; and MarshalJSON(), which encoding/json uses. Each returns [REDACTED]. Now the only way to see the real value is an explicit method such as Reveal(), which appears in a code review as a single obvious line.
What trips people up
Secrets on a command line are visible to anyone who can list processes. Use the environment or a file.
Example
package main
import (
"encoding/json"
"fmt"
"log/slog"
"os"
)
// Secret holds a string that must never reach a log or a response body.
type Secret string
// String is what fmt uses for %v, %s and %q.
func (s Secret) String() string { return "[REDACTED]" }
// LogValue is what log/slog asks for, added with slog in Go 1.21.
func (s Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
// MarshalJSON covers encoding/json, and so slog's JSON handler too.
func (s Secret) MarshalJSON() ([]byte, error) { return []byte(`"[REDACTED]"`), nil }
// Reveal returns the real value. Call it only where the secret is used.
func (s Secret) Reveal() string { return string(s) }
type Config struct {
User string
Password Secret
}
func main() {
cfg := Config{User: "app", Password: Secret("hunter2")}
fmt.Printf("%v\n", cfg) // the careless debug print
b, err := json.Marshal(cfg)
if err != nil {
panic(err)
}
fmt.Println(string(b))
drop := func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey { // dropped so the output never changes
return slog.Attr{}
}
return a
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: drop}))
log.Info("connecting", "password", cfg.Password)
fmt.Println("real length:", len(cfg.Password.Reveal()))
}
// Output:
// {app [REDACTED]}
// {"User":"app","Password":"[REDACTED]"}
// level=INFO msg=connecting password=[REDACTED]
// real length: 7
When to use it
- Print a whole config struct while debugging without the database password appearing in the log.
- Serve a JSON view of a webhook's settings without exposing its signing key.
- Read a token from a Kubernetes-mounted Secret file at startup and fail the pod if it is missing.
More examples
Loading a secret from a mounted file
A file is narrower than an environment variable: variables are copied into every child process and often end up in crash reports. Trimming whitespace matters more than it looks, because a trailing newline added by an editor produces an authentication failure with no useful message. Checking for empty turns a silently broken deployment into a startup error.
// Kubernetes mounts a Secret as a file; this reads it once at startup.
func secretFromFile(path string) (Secret, error) {
b, err := os.ReadFile(path)
if err != nil {
// The error names the path, never the contents.
return "", fmt.Errorf("read secret %s: %w", path, err)
}
s := Secret(strings.TrimSpace(string(b))) // files usually end in a newline
if s == "" {
return "", fmt.Errorf("secret %s is empty", path)
}
return s, nil
}Building a connection string so the password cannot escape in an error
Connection strings are the most common way a password reaches a log, because a failed connection produces an error containing the whole string and that error gets logged at the top of the program. Keeping the assembled string inside one function, and wrapping errors with the fields rather than the DSN, removes the path. Reach for this shape for any URL with credentials in it, including webhook and queue endpoints.
type DBConfig struct {
Host string
User string
Password Secret
Name string
}
// DSN is the only place the real password is assembled, and the result is
// handed straight to the driver rather than stored or logged.
func (c DBConfig) DSN() string {
return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=require",
url.QueryEscape(c.User), url.QueryEscape(c.Password.Reveal()), c.Host, c.Name)
}
func open(c DBConfig) (*sql.DB, error) {
db, err := sql.Open("pgx", c.DSN())
if err != nil {
// Wrap with the config, whose Password field redacts itself. Never
// wrap with the DSN: drivers put it in their own error text.
return nil, fmt.Errorf("open database %s@%s: %w", c.User, c.Host, err)
}
return db, nil
}A last-resort filter in the handler
The typed Secret is the real protection, because it works no matter what the field is called. This filter catches the other direction: a raw string logged under an obvious name by code that never heard of your Secret type, including code in a dependency that uses your handler. Treat it as a net, not a plan; a key spelled pwd still slips through.
// Belt and braces: whatever anyone logs, these keys never come out intact.
var sensitive = map[string]bool{
"password": true, "token": true, "authorization": true, "api_key": true,
}
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if sensitive[strings.ToLower(a.Key)] {
return slog.String(a.Key, "[REDACTED]")
}
return a
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Info("login attempt", "user", "ada", "password", "hunter2")
// {"time":...,"level":"INFO","msg":"login attempt","user":"ada","password":"[REDACTED]"}
Discussion