Why can fmt's %+v still print a secret field whose type has a String method?
answer
- the method lives on the field's type
- reflection cannot call what it cannot export
- receiver kind decides the method set
- one verb ignores Stringer completely
basics
~20 sfmt reaches struct fields by reflection and can only call String on a field it can take as an interface value. Unexported fields cannot be, a pointer-receiver String is not in a value's method set, and %#v ignores String entirely.
solid answer
~40 sGiving a secret its own type with `func (Secret) String() string { return "[REDACTED]" }` does work for the common case: when `fmt` walks a struct under `%v` or `%+v` it checks each field for `fmt.Stringer` and uses it. It breaks in three specific ways. If the field is **unexported**, `fmt` cannot obtain an interface for it, so it falls back to printing the underlying value in the clear. If `String` is declared on `*Secret` and the field holds a `Secret` value, the method is not in that value's method set and is skipped. And `%#v` asks for `fmt.GoStringer`, not `Stringer`, so it prints the raw contents regardless. A `String` method also does nothing for `encoding/json`, which uses `json.Marshaler` — you need `MarshalJSON` or a `json:"-"` tag as well.
code
go · 14 linestype Secret string
func (Secret) String() string { return "[REDACTED]" }
type Config struct {
Token Secret
apiKey Secret
}
func dump() {
c := Config{Token: "t0ps3cret", apiKey: "s3cr3t"}
fmt.Printf("%+v\n", c) // {Token:[REDACTED] apiKey:s3cr3t}
fmt.Printf("%#v\n", c) // asks for GoString, so both values appear
}go deeper
Know that giving a secret its own named type with a String method that returns a mask is how Go keeps it out of ordinary printing, and that %v and %+v are the verbs that use it.
Explain the mechanism: fmt reflects over fields and calls String only where it can obtain an interface value, so unexported fields and pointer-receiver methods slip through, and %#v bypasses Stringer.
Show the full safe-type recipe — value-receiver String, MarshalJSON, one conspicuous accessor — and say honestly what it does not cover, such as a core file or a value already copied into a plain string.
Decide whether this is a mandated convention across services, where it is enforced (a shared config package, a CI analyzer, review), and what you accept it will never cover.
## What fmt actually does with a struct When you write `fmt.Printf("%+v", cfg)`, `fmt` first looks at the top-level argument: if its dynamic type implements `fmt.Formatter`, `error`, or `fmt.Stringer`, that method decides the output. If not — and a plain struct does not — `fmt` falls back to reflection and walks the fields, printing `name:value` pairs for `%+v`. At each field it repeats the check: can this value be treated as an interface, and does it implement `Stringer`? That is why a *typed* secret works at all: ```go type Secret string func (Secret) String() string { return "[REDACTED]" } ``` A `Config` with an exported `Token Secret` field prints as `{Token:[REDACTED]}` under `%+v`. This is the single highest-leverage habit for keeping credentials out of diagnostics, because the thing that leaks a secret is almost never a `log.Print(password)` someone wrote on purpose — it is a whole struct dumped into an error, a panic message, or a debug endpoint by code that had no idea what it was holding. ## The three holes **1. Unexported fields.** Reflection can *read* an unexported field's value but cannot hand it out as an `interface{}`; `reflect.Value.CanInterface` is false for it. `fmt` therefore skips the method check for such fields and prints the underlying representation directly. So `Config{Token Secret; apiKey Secret}` redacts `Token` and prints `apiKey` in the clear under `%+v`. This is the surprising one, because the usual Go instinct — keep the field unexported so nobody outside the package can touch it — makes the redaction *worse*, not better. **2. Pointer receivers.** If you write `func (s *Secret) String() string`, then `Secret`'s method set does not include `String`; only `*Secret`'s does. A field declared as `Secret` (not `*Secret`) will not be redacted, and neither will a `Secret` passed by value to `fmt.Printf`. Declare redaction methods on the value receiver. **3. The verb.** `%#v` asks for `fmt.GoStringer` (`GoString() string`), not `Stringer`, and prints a Go-syntax representation including unexported fields when nothing implements it. `%v`, `%+v`, `%s` and `%q` route through `Stringer`; `%#v` does not. Someone debugging with `%#v` prints everything. ## Everything that is not fmt A `String` method is a `fmt` hook, and only a `fmt` hook: - `encoding/json` marshals exported fields and consults `json.Marshaler` (`MarshalJSON`), never `String`. A config struct serialised to a `/debug/config` handler leaks unless the type also implements `MarshalJSON` or the field carries `json:"-"`. - `structured logging` has its own hook: `slog.LogValuer` with a `LogValue` method. Types you intend to be safe in logs generally implement both. - Text templates, `spew`-style dumpers, database drivers and anything using reflection directly can read the value regardless. - And nothing stops `string(s)` or `[]byte(s)`: the method hides the value from formatters, not from code. ## How to make it hold The pattern that survives review is a named type — `type Secret string` or `type Secret []byte` — with a value-receiver `String`, a `MarshalJSON` that emits a constant mask, and a `LogValue` if the codebase uses structured logging. The concrete value comes out through one explicit accessor with a name nobody types by accident, such as `func (s Secret) Reveal() string`, which makes every real use of the plaintext greppable in review. Even then, treat it as defence in depth rather than a guarantee. It stops the accidental `%+v` — which is the overwhelming majority of real incidents — but it does not stop a deliberate print, it does not touch a core file or a heap dump where the plaintext is still findable by grep, and it does not help if the value was copied into a plain `string` somewhere upstream before it ever reached your type. The value of the convention is that the *default* path through your code is safe.
- Does implementing String stop encoding/json from emitting the value?No. `encoding/json` looks for `json.Marshaler` — a `MarshalJSON` method — and otherwise marshals exported fields by reflection. A `String` method is invisible to it. To make a secret type safe in JSON you implement `MarshalJSON` returning a constant mask, or tag the field `json:"-"` so it is omitted; and remember `UnmarshalJSON` if the same type has to be read back in.
- Where should the String method's receiver be, and why does it matter here?On the value receiver. With `func (s *Secret) String()`, only `*Secret` has the method, so a struct field declared as `Secret` — or a `Secret` passed by value into `fmt.Printf` — is printed in the clear. A value receiver puts `String` in both method sets, so both the value and a pointer to it redact.
- How would you keep the plaintext reachable but hard to leak?Expose exactly one accessor with a deliberately conspicuous name, such as `Reveal() string` or `Bytes() []byte`, and never make the type's underlying value directly usable elsewhere. Every real use of the plaintext is then a single greppable call site that review and CI can watch, while every accidental path — printing, logging, serialising — goes through the redacting methods.
A String method is a label on the outside of the box. Reflection reads the box's contents directly, and for unexported fields it never even looks at the label.
saying these in an interview costs you the question
- Believes a String method redacts the value everywhere
- Puts String on a pointer receiver and expects value fields to redact
- Thinks unexported fields are hidden from fmt
- Assumes encoding/json consults String
- Redacts the struct instead of typing the secret field