Articles / Go

  • go
  • slog
  • logging
  • secrets
  • security
  • debugging

Your Go Service Logs the Secret You Thought Was Redacted

You wrapped the API key in a type that prints as "[REDACTED]", checked the log line, and moved on. Then a request timed out and the full key appeared in the error message anyway. Redaction by type only protects the paths that go through that type, and Go has plenty of paths that do not.

The usual fix, and what it covers

The common approach is a string type that refuses to print itself. With log/slog you implement LogValuer; for everything else you implement String.

package secret

import "log/slog"

type Secret string

func (Secret) String() string { return "[REDACTED]" }

func (Secret) LogValue() slog.Value {
	return slog.StringValue("[REDACTED]")
}

func (Secret) MarshalText() ([]byte, error) {
	return []byte("[REDACTED]"), nil
}

// Reveal is the only way to get the real value back.
func (s Secret) Reveal() string { return string(s) }

That handles slog.Info("call", "key", key), fmt.Sprintf("%v", key), and both slog handlers when the value sits inside a struct, because JSONHandler and TextHandler both honour encoding.TextMarshaler.

The catch with MarshalText: it also redacts when you serialise the config to disk on purpose. If you do that, use a separate type for the wire format.

Leak one: the error that carries the URL

Suppose the key travels in the query string, as some APIs insist. When the call fails, http.Client returns a *url.Error, and its message includes the URL. The standard library strips a password from the userinfo part of the URL, but it does not touch the query string.

resp, err := http.Get("https://api.example.com/v1/items?token=" + tok.Reveal())
if err != nil {
	slog.Error("fetch failed", "err", err)
	// Get "https://api.example.com/v1/items?token=s3cr3t": context deadline exceeded
}

Your Secret type was gone the moment you concatenated it into a string. The best fix is to move the token into an Authorization header, where the error text never shows it. If you cannot, scrub the error before it reaches a logger:

func scrub(err error) error {
	var ue *url.Error
	if !errors.As(err, &ue) {
		return err
	}
	u, perr := url.Parse(ue.URL)
	if perr != nil {
		return err
	}
	q := u.Query()
	for k := range q {
		q.Set(k, "REDACTED")
	}
	u.RawQuery = q.Encode()
	cp := *ue
	cp.URL = u.String()
	return &cp
}

This is simplified: it returns the inner *url.Error and drops any wrapping context around it. It also blanks every query parameter, which is blunt but safe.

Leak two: the header map

Logging r.Header in a debug line is tempting, and it includes Authorization and Cookie. The same goes for httputil.DumpRequest, which writes headers verbatim. A header is just a []string, so your Secret type never had a chance to intervene.

Log an allow-list of headers you know are harmless, not a deny-list of the ones you remembered to worry about. New headers will appear; an allow-list fails safe.

Leak three: the struct someone logs with %+v

Quick detour, because this one is neat. LogValuer only applies to the value you hand to slog directly. If you pass a struct, slog does not walk its fields looking for LogValuer implementations.

So for a struct holding a plain string password, TextHandler prints it with %+v and JSONHandler calls json.Marshal. Neither knows the field is sensitive. Two fixes work:

  • Use the Secret type for the field, so String and MarshalText kick in.
  • Give the struct its own LogValue that returns a slog.GroupValue of only the safe fields.

Tagging a field json:"-" helps the JSON handler only. The text handler still prints it, which is an easy way to get a clean test and a leaky production config.

Leak four: ReplaceAttr matches names, not meaning

HandlerOptions.ReplaceAttr is the catch-all people reach for. It runs on every attribute after LogValuer values are resolved, so you can blank anything keyed "password":

h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
		switch a.Key {
		case "password", "token", "authorization":
			return slog.String(a.Key, "[REDACTED]")
		}
		return a
	},
})

This is a reasonable second line of defence. It only matches the key, though. The same token logged as "t", or embedded in a message string, or inside an error text, sails straight past.

What actually holds

  • Keep secrets in a type that redacts under String, LogValue and MarshalText, and make unwrapping explicit with Reveal.
  • Never put secrets in URLs; headers do not appear in url.Error.
  • Scrub errors from outbound calls before logging them.
  • Log headers by allow-list.
  • Treat ReplaceAttr as a backstop, not the mechanism.

Then test it the dull way: write a test that logs a known canary secret through every path your service uses, captures the output in a buffer, and fails if the canary string appears. It takes ten minutes and catches the leak the next person adds.