redact

redact removes secrets from log lines and HTTP dumps in a single pass, so the cost does not grow with the number of patterns enabled.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/redact"

Replaces secrets in text with a marker, before it reaches a log.

safe := redact.Default().String(rawPayload)

One pass, whatever the pattern count

Every pattern is matched in a single pass over the input. Enabling more patterns does not make redaction slower.

That property is what makes it usable on the logging path at all. The obvious implementation, a slice of regular expressions applied in sequence, costs one full scan per pattern, so a realistic set of twenty patterns scans the input twenty times. On a request dump of a few kilobytes, in a service logging every request, that becomes measurable and people start disabling redaction for performance.

Output shapes and their allocation cost

Five entry points run the same engine and differ only in how input and output are handled:

Entry pointns/opB/opallocs/op
AppendTo90100
Pooled897241
Bytes9242081
String1,0294083
AppendTo, digit-heavy input98000

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/redact/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

The engine costs about 900 ns on this input; the spread between rows is almost entirely allocation. AppendTo writes into a caller-owned buffer and allocates nothing, matching what a logging handler already has to hand. String costs 408 bytes across 3 allocations because it must produce a new immutable string.

The digit-heavy row is there to show the near-worst case for the number-matching patterns, and it is 9% slower than ordinary input rather than several times slower.

What the default catches

Default() is a shared zero-configuration instance covering Authorization-style headers, cookies, common sensitive key/value pairs, and card-like numbers.

WithLuhnCheck(true) gates card-number redaction on the Luhn checksum. Without it, any sixteen-digit run is treated as a card number, which redacts order numbers, timestamps concatenated together, and internal identifiers. With it, only sequences that could actually be card numbers are replaced, at the cost of missing a mistyped one.

Extending it

re := redact.New(
    redact.WithMarker("#REDACTED#"),
    redact.WithLuhnCheck(true),
    redact.WithExtraTokens("floof"),
)

WithExtraTokens adds key names specific to your domain. This matters because the default cannot know that your API calls its credential X-Floof-Key, and httpclient documents exactly this gap: arbitrary secret request headers are not covered by the default redactor.

WithMarker changes the placeholder, which is worth doing if the default collides with content you legitimately log.

Matching is convergent and boundary-exact

Two properties that keep the output trustworthy.

Convergent: redacting already-redacted text does not progressively mangle it. Text can pass through the redactor more than once, which happens when a payload is redacted before being embedded in a larger structure that is then redacted again.

Boundary-exact: a match replaces exactly the secret, not the surrounding punctuation or the following field. A redactor that over-consumes destroys the structure of a JSON log line, which is worse than not redacting, because the line becomes unparseable and the surrounding context is lost too.

It is a last line, not the only one

Redaction operates on text that has already been assembled, so it depends on recognising the secret’s shape. A secret in an unexpected format, a base64 blob, or a field name it does not know reaches the log.

Structured logging that never puts the secret in the record is the stronger control. This catches what gets through, particularly in request and response dumps where the content is not under your control.

Dependencies

This package reaches no external module: it uses only the Go standard library.