Redact Secrets at the Logger Config, Not at Every Call Site
One line — log.info(request) — and every API key, session token, and password on that request object just landed in plaintext in your logs, your log aggregator, and everyone with read access to it. The fix isn't more discipline. It's moving redaction to the one place every log line has to pass through: the logger config.
Why call-site redaction fails
The common pattern is to scrub secrets where you log them:
log.info("auth attempt", user=user.id, token="***REDACTED***")
This works until the day someone logs the whole object instead of hand-picked fields, adds a new sensitive field to the payload, or copies a debug log.info(request) from a branch and forgets to delete it. Call-site redaction is a rule every engineer must remember on every line forever. The failure mode is one forgotten field, and someone always forgets.
Redact once, at the logger
A processor (structlog) or filter (stdlib logging) sits in the pipeline that every event passes through. It inspects the event, replaces sensitive keys with a placeholder, and only then hands the record to the formatter and handlers. Nothing reaches disk or your aggregator unscrubbed.
import structlog
SENSITIVE = {"password", "token", "authorization", "api_key", "secret", "cookie"}
def redact(logger, method_name, event_dict):
for key in list(event_dict):
if key.lower() in SENSITIVE:
event_dict[key] = "***REDACTED***"
return event_dict
structlog.configure(processors=[redact, structlog.processors.JSONRenderer()])
A structlog processor is just a callable that takes (logger, method_name, event_dict) and returns the dict for the next processor in the chain. Put redact before the renderer and every event is scrubbed before it's serialized.
The standard library has the same seam. A logging.Filter subclass mutates the LogRecord before any handler emits it:
import logging
class RedactFilter(logging.Filter):
def filter(self, record):
for key in SENSITIVE:
if hasattr(record, key):
setattr(record, key, "***REDACTED***")
record.msg = scrub(record.msg) # regex over the message string
return True # keep the record; we only mutate it
Attach the filter to your handlers and it runs on every record.
Rules of thumb
| Decision | Do this |
|---|---|
| Where to redact | At the logger config (processor/filter), not the call site |
| What to match | A denylist of key names plus regex for values (bearer tokens, keys) |
| Key matching | Case-insensitive; normalize Authorization and authorization |
| On no match | Return/keep the record — a filter that returns True mutates, doesn't drop |
| Nested payloads | Walk dicts recursively; secrets hide inside headers, body, meta |
Power moves
- Match values, not just keys. A key-name denylist misses a bearer token that lands in a free-text message. Add regex substitution (
re.sub) for high-signal value shapes —Bearer [A-Za-z0-9._-]+,sk-[A-Za-z0-9]+— so a leaked value is caught even under an innocent key. - Redact recursively. Request objects nest. Your processor should descend into dicts and lists so
event["headers"]["authorization"]is scrubbed, not just top-level keys. - Fail closed on the pipeline. If a redaction processor raises, don't let the log line through raw. Wrap the body so an error degrades to a fully-masked record, never an unscrubbed one.
- Treat it as a security gate, not a nicety. Add a test that logs a known secret and asserts it never appears in the rendered output. That test is what makes "cannot be forgotten" true.
Resources
- structlog — how processors work
- Python
logging.Filter— standard library reference - Better Stack — logging sensitive data safely
- OWASP Logging Cheat Sheet — data to exclude
Building AI features that log a lot? Yeda AI designs, audits, and ships production LLM systems — including the logging and secrets hygiene around them. Talk to us · Read the blog