Sensitive data & secrets

How do you redact sensitive data from Go logs?

The direct answer is slog.LogValuer: list the fields that may appear. New fields stay silent. slog has been in the standard library since Go 1.21. Zap issues 453, 750, 836, and 993 are the right tickets for filtering and nested marshalers. The 4 ms to 10 ms p95 jump for reflection-based tag redaction is illustrative and was not re-run here.

8 min read
In short: The direct answer is slog.LogValuer: list the fields that may appear. New fields stay silent. slog has been in the standard library since Go 1.21. Zap issues 453, 750, 836, and 993 are the right tickets for filtering and nested marshalers. The 4 ms to 10 ms p95 jump for reflection-based tag redaction is illustrative and was not re-run here.

How do you redact sensitive data from Go logs?

Implement slog.LogValuer on every type that can contain a secret or a personal field. List the fields that are safe to emit. Anything you do not list is not logged. That is an allow-list, which is the point: a new field is silent until you opt it in.

log/slog has been in the standard library since Go 1.21. You do not need an extra module to get structured logs or this hook. Nested values that also implement LogValuer are redacted in turn, which is the behavior that is hard to get from Uber's Zap without writing a custom MarshalLogObject on every parent.

If you are still on Zap, you can keep it. Know that Zap's documented path (zapcore.ObjectMarshaler) does not reliably fire for nested structs when the parent is encoded with reflection. The rest of this page is why slog is the smaller design for redaction, and a complete example you can run.

Why shouldn't you log everything in production?

Debug logging is how you see a program. In production it is how you create a second copy of the customer record. Privacy questions arrive first: is there PII in the line, did the policy allow this use, how long do you keep it, and what happens on an erasure request? Security questions arrive next: session tokens, API keys, and connection strings in a log store that twenty people can query.

Turning DEBUG on for one customer is a reasonable incident tactic. It is only safe if the types in that path already know which fields are public. Otherwise you have just written the secret to long-term storage. How the secret got into the process in the first place is secrets exfiltration and storing secrets in environment variables.

How do Zap and slog compare for redaction?

Zaplog/slog (Go 1.21+)
Where it livesgo.uber.org/zapStandard library
Redaction hookzapcore.ObjectMarshalerslog.LogValuer
Nested structs

Parent reflection skips child MarshalLogObject (see zap#836)

Child LogValuer is honored

Default for new fieldsOften logged unless you maintain a denylist

Omitted until you add them to LogValue

PerformanceStill the faster library on Zap's own benchesFast enough for most APIs; no extra dependency

Zap remains a good logger. Filtering and redaction are the awkward part. The discussions in zap#453 (filtering middleware cores; closed as not fully generic) and zap#750 (elegant field filters) are still the right tickets to read. Caddy has an implementation you can copy. zap#993 asked for a sensitive struct tag; maintainers declined to add contextual scrubbing inside Zap. zap#836 is the nested-marshaler gap.

A custom Zap ObjectMarshaler on a secrets struct works when you log that struct with zap.Object or zap.Any. It does not run when a parent is reflected:

func (c SecretConfig) MarshalLogObject(enc zapcore.ObjectEncoder) error {
enc.AddString("DBUrl", fmt.Sprintf("**%s**", c.DBUrl.Host))
enc.AddString("CHUser", "**REDACTED**")
enc.AddString("CHPass", "**REDACTED**")
return nil
}

To make a parent Config redact SecretConfig, you end up reflecting every field, skipping the secret by name, then adding the child through AddObject so the child's marshaler runs. That is a denylist plus reflection. It is easy to get wrong the next time someone adds a field.

Why not slog's ReplaceAttr?

HandlerOptions.ReplaceAttr can rewrite each non-group attribute before it is written. It is the right tool for a global rule ("never log a key named email"). It is the wrong tool for "this struct decides its public surface."

Two problems:

  1. It is a denylist. You match keys you remember. A new BackupDBUrl is logged until you update the handler.
  2. Reflection over struct tags is not free. In Arcjet's own API, a generic tag-based redactor moved illustrative p95 latency from about 4 ms to about 10 ms, with slower outliers (measured on that service at the time of the original write-up; treat the numbers as an order-of-magnitude example, not a benchmark you can reproduce here). A tight SLA cannot spend that on every request.

LogValuer does the work once, when the type is designed, with no reflection on the hot path.

What does a complete slog redaction example look like?

Save this as main.go and run go run . with Go 1.21 or newer. The password and the ClickHouse fields never appear. A new field on SecretConfig also does not appear until you add it to LogValue.

package main
import (
"log/slog"
"net/url"
"os"
)
type SecretConfig struct {
DBUrl url.URL
CHUser string
CHPass string
}
func (o SecretConfig) LogValue() slog.Value {
return slog.GroupValue(
slog.String("db_url", "**"+o.DBUrl.Host+"**"),
slog.String("ch_user", "[redacted]"),
slog.String("ch_pass", "[redacted]"),
)
}
type Config struct {
Port int
SecretConfig SecretConfig
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(log)
dbURL, _ := url.Parse("postgres://payments:hunter2@db.internal:5432/app")
cfg := Config{
Port: 8080,
SecretConfig: SecretConfig{
DBUrl: *dbURL,
CHUser: "analytics",
CHPass: "hunter2",
},
}
slog.Info("starting", slog.Any("config", cfg))
}

Example output (pretty-printed):

{
"time": "2026-08-25T12:00:00.000Z",
"level": "INFO",
"msg": "starting",
"config": {
"Port": 8080,
"SecretConfig": {
"db_url": "**db.internal**",
"ch_user": "[redacted]",
"ch_pass": "[redacted]"
}
}
}

Port is logged because Config has no LogValuer and the default encoder walks exported fields. SecretConfig is logged through LogValue, so the userinfo in DBUrl is dropped and the ClickHouse pair is replaced. If you later add APIToken string to SecretConfig and forget to mention it, it does not appear. That is the property you want on call night when someone raises the log level.

What about free text you cannot type ahead of time?

LogValuer works because you know at compile time which field holds the secret. Support messages, prompts, webhook bodies, and stack traces are the other half of the problem: a single string that sometimes contains a card number because a customer pasted one.

You cannot solve that with an allow-list of fields. You have to look at the value. Arcjet's Go SDK exposes that as a Guard rule, which runs the analyzer in your process on arbitrary text and reports the entity types it matched rather than the text itself:

var guardClient, _ = arcjet.NewGuardClient(arcjet.GuardConfig{})
var pii, _ = arcjet.GuardSensitiveInfo(arcjet.GuardSensitiveInfoOptions{
Mode: arcjet.ModeLive,
Deny: []arcjet.EntityType{
arcjet.SensitiveInfoEmail,
arcjet.SensitiveInfoCreditCardNumber,
arcjet.SensitiveInfoPhoneNumber,
},
})
// logNote records free text only when it carries no PII the analyzer
// recognizes. The text stays in the process; only a hash is reported.
func logNote(ctx context.Context, note string) {
decision, err := guardClient.Guard(ctx, arcjet.GuardRequest{
Label: "logs.note",
Rules: []arcjet.GuardRuleInput{pii.Text(note)},
})
if err != nil {
slog.Warn("sensitive info scan failed", "err", err)
}
if result := pii.DeniedResult(decision); result != nil {
slog.Warn("note withheld from logs", "entities", result.DetectedEntityTypes)
return
}
slog.Info("note", "body", note)
}

Two properties matter for a logging path. The scan is local, so sending a value to be classified does not itself become the disclosure. And the failure mode is fail-open: err here means the rule could not be evaluated, not that the text is clean, so decide deliberately whether an unevaluated note gets logged. The bundled analyzer covers email, phone number, IP address, and credit card; other entity types need a backend.

The Go SDK is pre-release, so pin an exact version and expect the API to move. For the same idea in a request path rather than a log line, see secrets exfiltration.

Did switching to slog hurt production latency?

On Arcjet's decide API, moving from Zap to slog with LogValuer (not the reflection-and-tags path) showed no meaningful production regression. Developer experience improved: you design the type once, and a later field cannot leak just because someone logged the parent. Using the standard library for this also matches a "few dependencies" rule.

The graph from the original write-up had three bands: Zap (baseline), slog plus reflection on struct tags (the p95 jump), and slog plus LogValuer (back to baseline). If you re-measure, measure your own p95. The 4 ms to 10 ms figure is illustrative of "do not reflect on every request," not a number to put in a capacity plan.

Design the type as if it will be logged at DEBUG in production. If that thought makes you uncomfortable, you are missing a LogValue.

Frequently asked questions

How do you redact sensitive fields in slog?

Implement slog.LogValuer on the type and return a GroupValue of the fields you allow. Fields you omit are not logged, including fields added later.

How do you redact a value whose shape you do not know?

LogValuer needs a typed field. For free text such as a support message or a prompt, scan the value instead. Arcjet's Go SDK has GuardSensitiveInfo, which runs a local analyzer over a string and reports matched entity types rather than the text. The bundled analyzer covers email, phone number, IP address, and credit card.

When did slog land in the standard library?

Go 1.21. You do not need an extra module for structured logs or LogValuer.

Why is Zap awkward for this?

ObjectMarshaler does not run for nested structs when the parent is encoded with reflection (zap#836). Filtering cores were discussed in zap#453 and zap#750 and are not a fully generic built-in.

Is the 4 ms to 10 ms p95 figure a current benchmark?

No. It is an illustrative measurement from Arcjet's API when a reflection-and-tags redactor was tried. Re-measure on your service. LogValuer avoided that jump in that write-up.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.