Keep Secrets out of Logs and Tracebacks

Typing a field as SecretStr closes the paths where your code renders a value. It does nothing about the paths where somebody else’s code does — a database driver that puts the connection string in its exception message, an HTTP client that logs full URLs including a query-string token, a framework that dumps a request body at DEBUG. Closing those needs a different set of controls, applied at the logging pipeline and at the boundaries where third-party code raises. This page covers them, extending the secret types and redaction topic.

Problem 1: the exception that carries the connection string

# ANTI-PATTERN: whatever the driver says goes straight to the log
try:
    conn = psycopg.connect(str(settings.database_url))
except Exception:
    logger.exception("database connection failed")   # message may contain the password

The application did nothing wrong by its own lights: it logged an exception with no formatting of its own. But the exception was constructed by a library that had the full connection string and considered including it in the message to be helpful. Once that exception reaches a log handler, the credential is written wherever logs go, and logger.exception also emits the traceback, which may repeat it.

The fix is to treat the client call as a boundary and re-raise with a message you control. Catch the library’s exception, log a description built from your own safe summary, and chain the original with from only if you are confident the chained message is safe — or attach a sanitised representation instead. The cost is a few lines per client; the benefit is that no library’s idea of a helpful error message can reach your log unfiltered.

Wrapping a driver exception at the client boundary A driver exception containing the connection string is caught at the client boundary and replaced with a message built from a safe summary, so only the safe form reaches the log. driver raises message holds the DSN client boundary catch, re-raise, describe your message host and database only log without the middle box, the driver decides what your logs contain
The boundary exists so a third party's idea of a useful error message never becomes your log entry.

Problem 2: a regex scrubber used as the primary control

# ANTI-PATTERN: a filter that must predict every shape a secret can take
SECRET_RE = re.compile(r"(password|token)=\S+", re.I)

class Scrubber(logging.Filter):
    def filter(self, record):
        record.msg = SECRET_RE.sub(r"\1=***", str(record.msg))
        return True

This catches password=hunter2 and misses "password": "hunter2", pwd=hunter2, a bare token in a URL path, a base64 blob, and anything the next library invents. It also rewrites record.msg unconditionally, which stringifies lazy log messages and defeats the deferred-formatting optimisation the logging module is built around — so it costs performance on every record while providing coverage nobody can characterise.

A scrubber is still worth having, but only as the last line. Its job is to catch what escaped the type system and the boundary wrapping, primarily from third-party code. Keep the pattern narrow, apply it to the formatted output rather than to lazy arguments, and never let its presence justify relaxing anything upstream. The failure mode to avoid is a team that believes the scrubber makes the logs safe and stops thinking about the question.

Problem 3: request logging that includes headers and bodies

# ANTI-PATTERN
logger.info("request", extra={"headers": dict(request.headers), "body": await request.body()})

Inbound credentials — an Authorization header, a session cookie, an API key, a password in a form post — never pass through your settings model, so no typed field protects them. Logging headers or bodies wholesale writes other people’s credentials into your log, which is both a security problem and, in most jurisdictions, a data-protection one.

Log a denylisted subset. Header names are known and finite, so filter by name before the record is created; bodies should be summarised — size, content type, a field-name list — rather than included. If a specific debugging session genuinely needs bodies, enable it behind a flag with an expiry and treat the resulting log as sensitive for its whole retention period.

Secure implementation

# logging_setup.py — the backstop, applied to formatted output only
import logging
import re

# 1. narrow patterns, aimed at what third-party code emits — not at your own values
PATTERNS = [
    re.compile(r"(://[^:/@\s]+:)([^@\s]+)(@)"),          # credentials inside a URL
    re.compile(r"((?:api[_-]?key|token|secret)\W{1,3})([A-Za-z0-9\-_]{12,})", re.I),
]

SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key", "proxy-authorization"}


class SecretScrubbingFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        # 2. format first, then scrub once — lazy args are never stringified early
        rendered = super().format(record)
        for pattern in PATTERNS:
            rendered = pattern.sub(lambda m: m.group(1) + "***" + (m.group(3) if m.lastindex == 3 else ""), rendered)
        return rendered


def safe_headers(headers: dict[str, str]) -> dict[str, str]:
    # 3. filter by name before a record exists, so nothing to scrub later
    return {k: ("***" if k.lower() in SENSITIVE_HEADERS else v) for k, v in headers.items()}
# db.py — the boundary that stops a driver deciding what your logs say
import logging
import psycopg
from config import settings

logger = logging.getLogger(__name__)


class DatabaseUnavailable(RuntimeError):
    pass


def connect() -> psycopg.Connection:
    try:
        return psycopg.connect(str(settings.database_url))
    except psycopg.OperationalError as exc:
        # 4. our message, built from the safe summary; the original is not chained
        logger.error("database connection failed: %s", settings.database_summary)
        raise DatabaseUnavailable(f"cannot reach {settings.database_summary}") from None

The from None in the re-raise is doing real work. Chaining with from exc preserves the original exception, and with it the original message — which is the thing you were trying to keep out of the log. Suppressing the context means the traceback shows your exception alone. When the original detail genuinely matters for debugging, log a sanitised summary of it explicitly rather than letting the full object through: type(exc).__name__ is usually the useful part, and it carries no payload.

Scrubbing in a Formatter rather than a Filter is the other deliberate choice. A filter runs before formatting and sees record.msg with separate record.args, so scrubbing there means either stringifying early or missing values that arrive through arguments. Formatting first and scrubbing the result costs one pass over a string that was going to be produced anyway, and it sees exactly what would have been written.

Layered controls, strongest first Typed secret fields prevent most leaks, boundary wrapping stops third-party exception messages, header and body filtering handles transiting credentials, and a narrow formatter scrubber is the final backstop. 1. typed fields — your own values never render 2. boundary wrapping — a driver cannot write your log entry 3. header and body filtering — transiting credentials by name 4. formatter scrubber — narrow, last resort, never the plan
Each layer catches what the one above could not — and the bottom layer is the weakest, not the main defence.

Structured logging changes the calculation

If you emit structured records — JSON lines through structlog, python-json-logger or a framework’s own emitter — the backstop gets substantially better, because a processor sees fields as data rather than as rendered text. Dropping a key called authorization is exact; matching it inside a formatted string is guesswork. A processor that removes a denylist of key names, applied recursively so nested dictionaries are covered, catches a whole class of accidental inclusion with no pattern matching at all.

The same processor is the right place to enforce a rule about what shape a log field may take. Emitting whole objects into log fields is how secrets travel: somebody logs settings=settings or request=request and the serialiser walks the object graph. A processor that rejects or summarises unknown object types keeps records to primitives, which is both safer and considerably cheaper to index.

Structured logging also makes the log a queryable dataset, which cuts the other way for retention: values that would have been buried in a text blob are now indexed fields somebody can search across months of history. That raises the value of the earlier layers, and it argues for treating any field name that could ever hold a credential as a permanent denylist entry rather than something to clean up later.

Gotchas and version-specific behaviour

  • logger.exception emits the traceback and the exception’s message; wrapping at the boundary is what keeps that message safe.
  • raise ... from None suppresses the chained original, which is usually what you want when the original message carries a credential.
  • A Filter sees record.msg and record.args separately; a Formatter sees the rendered result, which is where scrubbing belongs.
  • Many client libraries only log request details at DEBUG, so a production level of INFO removes a real category of leak — and is not sufficient by itself.
  • Uncaught exceptions go through sys.excepthook, which does not pass through your logging configuration at all; install a hook if you rely on scrubbing.
  • warnings and third-party loggers inherit the root configuration, so attach the formatter at the root handler rather than per-logger.
  • Log aggregation agents often add their own fields — the container’s environment, for instance — which can reintroduce values you carefully kept out of the record.

One consequence of all this is worth stating plainly: log retention is part of your security posture. A leak found today is bounded by how long the affected system keeps data and by how many places it was copied to, so shortening retention on high-volume application logs is a genuine control rather than a cost-saving measure.

Production parity checklist

  • Every credential field is typed, so the primary control is the schema rather than the pipeline.
  • Client calls that can raise with a credential in the message are wrapped and re-raised with your own text.
  • Request logging filters headers by name and never includes raw bodies.
  • The root handler uses a scrubbing formatter with narrow, reviewed patterns.
  • Production log level is above DEBUG, and any temporary lowering has an expiry.
  • An excepthook or equivalent ensures uncaught exceptions pass through the same treatment.
Paths a credential can take into a log Credentials reach logs through your own formatting, a third-party exception message, request headers and bodies, or an aggregation agent adding environment fields. your own formatting a library's exception message request headers and bodies agent-injected environment fields closed by types needs boundary wrapping needs name filtering needs agent configuration four paths only one is closed by typing
Typing handles the first path completely and the other three not at all.

Frequently asked questions

Is a regex-based log scrubber enough?

No, and relying on one is the common mistake. It only catches shapes you predicted, it mangles legitimate output that happens to match, and it runs on every log record in the hot path. Use it as a backstop behind typed secrets, never as the primary control. The specific danger is organisational rather than technical: once a scrubber exists, it becomes an argument for not fixing the actual leak — “the filter will catch it” — and nobody re-examines whether it does. Keep the patterns narrow and document explicitly that the filter is a last resort for third-party output.

How does a credential get into a traceback if the field is a SecretStr?

Through a local variable or a third-party exception. Once your code unwraps the value, the raw string lives in that frame, and any library that builds an error message containing a connection string is outside your type system entirely. Both are addressed structurally: unwrap as close to the client call as possible so the raw value has the shortest possible life in a named local, and wrap client calls so the library’s exception never propagates with its original message. The remaining gap — a crash inside the library before your handler runs — is covered by the excepthook and the formatter.

Should I lower the log level in production to be safe?

Running above DEBUG is a genuine control, because many libraries only log request bodies, connection details and full URLs at DEBUG. It is not sufficient on its own — plenty of leaks happen at INFO or in exception handlers running at ERROR. Treat the level as one layer among several, and be especially careful about temporarily raising verbosity during an incident: that is precisely when people enable debug logging on a production service under pressure, and precisely when the resulting log is most widely shared.

What about structured logging processors?

They are the best place for a backstop, because they see fields as data rather than as a formatted string. A processor can drop keys by name reliably, recursively through nested dictionaries, which a regular expression over rendered text cannot do with any confidence. Add a second processor rule that keeps log fields to primitives, so nobody can log an entire settings object or request object and have the serialiser walk it. The two together give you a backstop that is both cheaper and far more predictable than pattern matching.

Key takeaways

Typed secrets close the paths where your own code renders a value, and they do nothing for the three other routes into a log: a library’s exception message, credentials transiting in headers and bodies, and fields an aggregation agent adds. Each needs its own control — boundary wrapping with from None, name-based filtering applied before the record exists, and a review of what the agent injects.

Keep the regex scrubber last and narrow. It exists to catch what escaped from code you do not control, not to make the rest of the design safe, and treating it as the primary defence is how teams end up with logs they believe are clean. Scrub in a formatter rather than a filter so lazy arguments are not stringified early, prefer a structured processor that drops keys by name where you have one, and remember that everything already written stays written — a leak found today is a rotation, not just a patch. The wider framing is on the secret types and redaction topic page.