API Logging in Go Without Leaking Secrets or Tokens
Keep passwords, tokens and API keys out of Go API logs with a Secret type, slog ReplaceAttr redaction, query-string scrubbing and header allow-lists.

Logs are the second database of every service, with weaker access control than the first. Developers read them, support reads them, a log vendor stores them, and they are copied to laptops, tickets and chat. A password or token that reaches a log line is exposed to all of those readers for as long as the retention period lasts, and logs are rarely encrypted the way a credentials table is.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Leaks rarely come from someone deciding to log a password. They come from log.Printf("request: %+v", req), from an error message that embeds a URL with an API key in the query string, from a debug statement left in, from logging a whole struct that gained a Token field last quarter. This article builds defence in layers using log/slog from the standard library: make secrets unloggable by type, catch the rest by name and pattern, and log requests from an allow-list. It ties into the security checklist and the request tracing article, which both rely on logs being safe to share. It ran on Go 1.25.6.
What goes wrong without any help
The first two lines of the lab output are an ordinary logger being used the ordinary way:
level=INFO msg="login failed" [email protected] password=hunter2 authorization="Bearer abc.def.ghi"
level=INFO msg="upstream call" error="Post \"https://pay.example.test/charge?api_key=sk_live_123\": timeout"The first is an obvious mistake: the developer put the password in the line. The second is subtler and far more common: the error value came from the HTTP client, and net/http includes the full URL in its error text. The API key was in the query string, so now it is in the log. Nobody wrote the key into a log call.
Layer 1: types that cannot be logged
The strongest control is making the dangerous value hard to print. slog has a LogValuer interface: if a value has a LogValue() slog.Value method, the logger calls it instead of formatting the value. A string type that returns [REDACTED] makes any attribute of that type safe.
But the experiment found the catch. A Secret with only LogValue protects log.Info("x", "password", secret), because slog sees the attribute's value directly. When the secret sits inside a struct, slog formats the struct as a whole with fmt, and fmt does not look for LogValue on nested fields. The output was login="{Email:[email protected] Password:hunter2}".
The fix is to make the type safe under every formatter it can meet:
LogValue()for slog,String()forfmtverbs like%vand%+v, which also coverslog.Printfand error wrapping,MarshalJSON()so thatjson.Marshalof a struct containing the secret, for example in a debug endpoint or a response, does not write it.
The lab defines both a weak and a complete version so you can see the difference. With all three methods, the struct prints as Password:[REDACTED].
package main
import (
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"regexp"
"strings"
)
// Secret is a string that cannot be logged by accident: slog calls LogValue instead of reading the field.
type Secret string
func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
// String covers fmt: a Secret inside a struct printed with %v or %+v goes through String, not LogValue.
func (Secret) String() string { return "[REDACTED]" }
// MarshalJSON covers json.Marshal, which would otherwise write the raw string.
func (Secret) MarshalJSON() ([]byte, error) { return []byte(`"[REDACTED]"`), nil }
// OnlyLogValue has the method slog looks for and nothing else, to show what that alone does not protect.
type OnlyLogValue string
func (OnlyLogValue) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
var sensitiveKeys = []string{"password", "token", "secret", "authorization", "cookie", "api_key", "apikey", "card"}
var (
bearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._~+/=-]+`)
jwt = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`)
)
// redactor is the safety net behind the typed approach: it catches what a developer logged without thinking.
func redactor(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey { // keep the demonstration output stable
return slog.Attr{}
}
key := strings.ToLower(a.Key)
for _, s := range sensitiveKeys {
if strings.Contains(key, s) {
return slog.String(a.Key, "[REDACTED]")
}
}
if a.Value.Kind() == slog.KindString {
v := a.Value.String()
v = bearer.ReplaceAllString(v, "Bearer [REDACTED]")
v = jwt.ReplaceAllString(v, "[JWT REDACTED]")
a.Value = slog.StringValue(v)
}
return a
}
// safeURL removes the values of sensitive query parameters, because URLs are logged by every proxy and server.
func safeURL(u *url.URL) string {
q := u.Query()
for k := range q {
for _, s := range sensitiveKeys {
if strings.Contains(strings.ToLower(k), s) {
q.Set(k, "REDACTED")
}
}
}
c := *u
c.RawQuery = q.Encode()
return c.RequestURI()
}
// headerAllowList logs only headers we have decided are safe and useful. A deny-list fails the day someone adds X-Session-Key.
var headerAllowList = []string{"User-Agent", "Content-Type", "X-Request-Id", "Accept"}
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hdr := []any{}
for _, h := range headerAllowList {
if v := r.Header.Get(h); v != "" {
hdr = append(hdr, slog.String(strings.ToLower(h), v))
}
}
log.Info("request", "method", r.Method, "uri", safeURL(r.URL), slog.Group("headers", hdr...), "has_auth", r.Header.Get("Authorization") != "")
next.ServeHTTP(w, r)
})
}
type Login struct {
Email string
Password Secret
}
type WeakLogin struct {
Email string
Password OnlyLogValue
}
func main() {
rawLog := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}}))
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: redactor}))
fmt.Println("== without protection")
rawLog.Info("login failed", "email", "[email protected]", "password", "hunter2", "authorization", "Bearer abc.def.ghi")
rawLog.Info("upstream call", "error", `Post "https://pay.example.test/charge?api_key=sk_live_123": timeout`)
fmt.Println("== with typed Secret, key matching and pattern matching")
log.Info("login failed", "email", "[email protected]", "password", "hunter2", "authorization", "Bearer abc.def.ghi")
log.Info("struct, LogValue only", "login", WeakLogin{Email: "[email protected]", Password: "hunter2"})
log.Info("struct, with String too", "login", Login{Email: "[email protected]", Password: "hunter2"})
log.Info("typed secret", "password_field", Secret("hunter2"), "note", Secret("anything"))
log.Info("token in a message", "msg_detail", "retrying with Bearer eyJhbGciOi.eyJzdWIiOi.SflKxwRJSM after 401")
log.Info("jwt alone", "detail", "got eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk failed")
fmt.Println("== request logging middleware")
h := requestLog(log, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
req := httptest.NewRequest("GET", "/v1/items?page=2&api_key=sk_live_123&q=go", nil)
req.Header.Set("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.dBjftJeZ4CVPm")
req.Header.Set("Cookie", "session=abc123")
req.Header.Set("User-Agent", "demo/1.0")
req.Header.Set("X-Request-Id", "req-42")
h.ServeHTTP(httptest.NewRecorder(), req)
}== without protection
level=INFO msg="login failed" [email protected] password=hunter2 authorization="Bearer abc.def.ghi"
level=INFO msg="upstream call" error="Post \"https://pay.example.test/charge?api_key=sk_live_123\": timeout"
== with typed Secret, key matching and pattern matching
level=INFO msg="login failed" [email protected] password=[REDACTED] authorization=[REDACTED]
level=INFO msg="struct, LogValue only" login="{Email:[email protected] Password:hunter2}"
level=INFO msg="struct, with String too" login="{Email:[email protected] Password:[REDACTED]}"
level=INFO msg="typed secret" password_field=[REDACTED] note=[REDACTED]
level=INFO msg="token in a message" msg_detail="retrying with Bearer [REDACTED] after 401"
level=INFO msg="jwt alone" detail="got [JWT REDACTED] failed"
== request logging middleware
level=INFO msg=request method=GET uri="/v1/items?api_key=REDACTED&page=2&q=go" headers.user-agent=demo/1.0 headers.x-request-id=req-42 has_auth=trueUse Secret for every field that holds a credential in your own structs: configuration, request types, database models, clients. It costs one conversion where the raw value is actually needed (string(secret) when building the Authorization header) and removes a whole category of mistake. A type is better than a convention because the compiler enforces it and a code reviewer does not have to remember anything.
Layer 2: redaction as a safety net
Types protect fields you control. They cannot protect an error string built by a library or an attribute added by someone who did not read this article. For those, slog.HandlerOptions.ReplaceAttr runs on every attribute before output, and is the right place for a last-line filter. The redactor function in the lab does two things:
- By key. If the attribute name contains
password,token,secret,authorization,cookie,api_key,apikeyorcard, the value is replaced. The first protected line showspassword=[REDACTED] authorization=[REDACTED]. Matching on substrings catchesrefresh_token,x-api-keyandcard_number. - By pattern, in string values.
Bearer <token>becomesBearer [REDACTED], and anything shaped like a JWT (eyJ...three dot-separated segments) becomes[JWT REDACTED]. The output shows both, in a message that was not under a sensitive key.
Be honest about what this layer is: heuristics. A pattern catches the formats you thought of. An opaque API key such as sk_live_123 has no shape a regular expression can rely on, which is why key-based and type-based protection come first, and why the lab's second raw example (the api_key in the error string) is handled by URL scrubbing and not by this layer.
Keep the patterns cheap. This function runs on every attribute of every log line; compile regular expressions once (as the lab does with package-level variables) and skip the pattern pass on non-string kinds. In a hot path, measure the logging overhead, and see benchmarking handlers for the method.
Layer 3: URLs
URLs are logged everywhere: by your handler, by proxies, by load balancers, by browsers' histories, by Referer headers sent to other sites. Anything sensitive in a query string leaks to all of them, so the first rule is to never put secrets in URLs at all; a bearer token belongs in a header. The second rule is to scrub what arrives anyway.
safeURL parses the query, replaces the value of any parameter whose name looks sensitive, and rebuilds the request URI. The middleware output at the bottom shows api_key=REDACTED while page and q remain readable. Apply the same function to URLs inside error messages from your own HTTP client calls: wrap the error, or log a scrubbed copy of the URL beside a generic message, instead of printing the library's error text.
Layer 4: log what you chose, not what you did not exclude
The request logging middleware builds its header list from an allow-list: User-Agent, Content-Type, X-Request-Id, Accept. A deny-list that removes Authorization and Cookie works until someone introduces X-Session-Key or a partner sends X-Signature, at which point it logs them silently. An allow-list fails closed: a new header is invisible until somebody decides it is worth logging.
The middleware output shows what happened with a request that carried an Authorization bearer token and a session cookie. Neither header appears. The log has a boolean has_auth=true, which is usually the only thing you need, and the request id that connects the line to everything else.
Apply the same philosophy to bodies: do not log request or response bodies by default. If you need to for debugging, log them behind a flag, sample a small percentage, run them through an allow-list of fields, and set a short retention for the destination.
Other places secrets hide
Panics and stack traces. A recovered panic printed with %v of the request or its context may include everything in them. Log the panic value and the stack, nothing from the request.
Errors that wrap data. Errors from database drivers can include query parameters, and some HTTP clients include URLs. Treat third-party error text as untrusted when logging.
Environment and config dumps. A startup log line that prints the whole configuration is a classic leak. Print a list of keys that are set, or the config with Secret fields, which are now safe.
Metrics and traces. Span attributes and metric labels have the same exposure as logs, and often longer retention. Apply the same rules, and never use a user id, email or token as a metric label.
Third-party loggers and middleware. The framework's default access log may print the query string or headers on its own. Replace it or configure it, and test with a real token to see what comes out.
Test it
Write a test that logs a canary secret through every path your service uses (a direct attribute, a struct, an error from a failed client call, a request through the middleware) and fails if the canary string appears in the output. A unique value such as canary-7f3a is easy to search for. Run it in CI so a new logging path cannot ship without it. Add the same canary to an integration test that captures the real logger output.
Finally, limit blast radius for the leak that happens anyway: short retention for logs, access control on the log store, and a rotation procedure for any credential that appears. The standard library documents the interfaces used here in log/slog, and the OWASP logging cheat sheet lists the data that should never be recorded.


