skip to content

In log/slog, why does a String method on a token type fail to keep the token out of JSON output?

level: middleimportance: should knowfreq 30%

answer

  1. two encoders, two different conventions
  2. which package actually formats the value
  3. it works locally and leaks in production
  4. encoding/json has never heard of Stringer
  5. %+v honours Stringer, MarshalJSON does not

basics

~20 s

A String method satisfies fmt.Stringer, which only the fmt package consults. slog's JSON handler encodes an unknown value with encoding/json, which looks for MarshalJSON and struct tags and never calls String, so the real value is written.

solid answer

~40 s

`String() string` satisfies `fmt.Stringer`, and that interface means something only to code that formats through `fmt`. slog's text handler happens to fall back to `fmt.Sprintf("%+v", ...)` for a value of an unrecognised type, so a `String` method does redact it there — which is exactly why the bug survives local testing. The JSON handler takes a different path: it hands the value to `encoding/json`, which consults `json.Marshaler` and struct tags and has no idea `fmt.Stringer` exists. The token is written in full. The hook that is handler-independent is `slog.LogValuer`: slog calls `Value.Resolve` before dispatching to any handler, so `LogValue` runs whether the sink is JSON, text or a handler you wrote yourself. `String` is a display convention; `LogValue` is slog's contract.

code

go · 11 lines
go
type Secret string

func (s Secret) String() string { return "REDACTED" }

// text handler: falls back to %+v, which calls String
slog.New(slog.NewTextHandler(os.Stdout, nil)).
	Info("load", slog.Any("token", Secret("sk-live-9f2c")))

// JSON handler: hands the value to encoding/json, which does not
slog.New(slog.NewJSONHandler(os.Stdout, nil)).
	Info("load", slog.Any("token", Secret("sk-live-9f2c")))

go deeper

for a junior

Know that String belongs to the fmt package and is used when something formats with %v or %s. It is not a general rule that every encoder follows.

for a middle

Be able to trace the two paths: the text handler falls back to fmt formatting, the JSON handler goes through encoding/json. Name LogValue as the hook that runs before either.

for a senior

Talk about how this reaches production: redaction verified against a text handler locally, JSON in the deployed service, and a grep of shipped output as the thing that finally catches it.

for a principal

Push for a mechanism that does not depend on handler configuration at all, and for a test in the shared logging library that encodes a fixture record and asserts the secret literal is absent.

## Three different interfaces, three different consumers The confusion here is that Go has several "turn this into text" conventions and they are honoured by different packages: - `fmt.Stringer` — `String() string`. Consulted by the `fmt` package when formatting with `%v`, `%+v` or `%s`. - `encoding.TextMarshaler` — `MarshalText() ([]byte, error)`. Consulted by packages that want a textual encoding, including slog's text handler. - `json.Marshaler` — `MarshalJSON() ([]byte, error)`. Consulted by `encoding/json`. - `slog.LogValuer` — `LogValue() slog.Value`. Consulted by `log/slog` itself, before any handler runs. A `String` method buys you the first one. Whether it protects a secret therefore depends entirely on which of the other packages ends up doing the encoding. ## What each standard handler actually does When an attribute's value is of a type slog does not have a dedicated kind for, its kind is `slog.KindAny` and the two built-in handlers diverge: **The text handler** first checks whether the value implements `encoding.TextMarshaler` and uses `MarshalText` if so. Otherwise it formats the value with `fmt.Sprintf("%+v", ...)`. `%+v` goes through `fmt`, which consults `fmt.Stringer`. So a `String` method returning `"REDACTED"` does redact under the text handler. **The JSON handler** hands the value to `encoding/json`. That package looks for `json.Marshaler`, then `encoding.TextMarshaler`, then falls back to reflecting over the exported fields and honouring `json` struct tags. `fmt.Stringer` is never in that list. A `type Secret string` with a `String` method marshals as its underlying string — the secret, in full, in the shipped record. ## The shape of the incident This mismatch is why the bug is so durable. A developer adds `String() string { return "REDACTED" }`, runs the service locally where the handler is `slog.NewTextHandler(os.Stderr, nil)` for readability, sees `token=REDACTED`, and ships. Production is configured with `slog.NewJSONHandler` because the log pipeline wants structured records, and the same line writes `"token":"sk-live-9f2c"`. Nobody notices until somebody greps a day of shipped output for the credential's prefix. The general lesson: a redaction mechanism that depends on which handler is installed is not a redaction mechanism. It is a coincidence of formatting. ## Why LogValuer is different `LogValue` is not consulted by the handler at all. slog itself calls `Value.Resolve()` on the attribute's value on the way in, and only the resolved result is dispatched. That means: - the JSON handler, the text handler, and a handler somebody writes next quarter all receive the placeholder; - a handler author cannot forget to support it, because there is nothing to support; - the substitution is a `slog.Value`, so it can be a string, a number, or a whole group of safe fields, rather than only a display string. ```go type Secret string func (s Secret) LogValue() slog.Value { return slog.StringValue("REDACTED") } ``` It is entirely reasonable to implement both: `String` so that a stray `fmt.Printf("%v", s)` or a `%+v` in an error message is also safe, and `LogValue` so that slog is covered regardless of handler. They protect different escape routes, and neither substitutes for the other. ## The routes neither one closes Be honest about the residue, because an interviewer will push here: - **Conversion.** `slog.String("token", string(s))` produces an attribute of kind string. There is no method on a `string`, and neither `String` nor `LogValue` runs. - **The message.** `logger.Info(fmt.Sprintf("using token %s", s))` does go through `fmt`, so `String` saves you and `LogValue` does not — but the value is now inside the message text where no attribute-level rule can reach it. - **Nesting.** If the secret is a field of a struct and the struct is logged whole, slog resolves the struct, not the field. Under the JSON handler `encoding/json` walks the fields directly; a field type's `LogValue` is never called. - **A different sink.** A stack trace, a crash report or a database row containing the value obeys none of these interfaces. That residue is the argument for a second, handler-level control — `HandlerOptions.ReplaceAttr` — as a backstop, and for a test that encodes a fixture record and asserts the secret literal is absent from the bytes.

  • Would implementing encoding.TextMarshaler instead have fixed it?
    Partly. slog's text handler checks for `encoding.TextMarshaler` before falling back to `%+v`, and `encoding/json` also uses `MarshalText` for a type that has it and no `MarshalJSON`. So it covers more ground than `String` alone. But it is still an encoder-level convention: a custom handler that formats values its own way owes it nothing, whereas `LogValue` is resolved before any handler is involved.
  • Should you implement both String and LogValue on a secret type?
    Yes, if the value can plausibly reach `fmt`. `LogValue` covers slog on every handler; `String` covers `%v` and `%+v` in error messages, `fmt.Printf` debugging and anything else that formats through `fmt`. They close different escape routes and cost two short methods. What you must not do is implement `String` alone and believe slog is covered.
  • Why does slog.String("token", string(tok)) defeat both methods?
    The conversion happens before slog sees anything. `string(tok)` produces a plain `string`, so the attribute's value has kind string and carries no methods at all — there is nothing for `Resolve` to call and nothing for `fmt` to consult. This is why review rules usually forbid converting a secret type on the way into a log call.

saying these in an interview costs you the question

  • Says fmt.Stringer is a general serialization interface
  • Believes encoding/json falls back to String when present
  • Tests redaction only against the text handler
  • Thinks a placeholder in one handler proves it everywhere
  • Confuses MarshalText with MarshalJSON precedence