Redacting Sensitive Data in Python Log Records

A connection string in an exception message, a bearer token interpolated into a debug line, a customer record in a repr() — every one of those is a log entry you cannot delete from a retention system after the fact. This page shows the filter that stops them, where it has to sit to be effective, and the three fields most implementations forget. It builds on the exception and traceback logging guide and is part of the Python logging fundamentals and structured data section.

Placement matters more than the pattern. A perfect regex on the wrong side of a queue protects nothing.

Where redaction still works, and where it is already too late A log record travels left to right from the call site through four possible redaction points. Redacting at the call site works but relies on every developer remembering, so coverage is never complete. Redacting in a filter attached to the logger runs on the producing thread before anything else touches the record, and is the placement that covers every sink. Beyond that point the record crosses the queue boundary, where it may be copied, pickled and handed to a background thread or another process; a filter attached to a handler runs after that crossing, and if two handlers are configured the record has already been written by the first one before the second one's filter runs. Redacting in the formatter is later still and covers only the fields that formatter happens to render. Once the record reaches a remote sink the data has left the process and no amount of filtering brings it back. four places you could redact — one of them is the right one at the call site works, but relies on everyone remembering filter on the logger producing thread covers every sink, once queue handler filter runs after the copy and per handler formatter only the fields it renders still contained one process, one heap, nothing written anywhere already too late in the general case with two handlers the first one has written the record before the second one's filter runs a filter on the logger is the only placement that runs exactly once, on the producing thread, ahead of every destination everything to the right of the queue is defence in depth, not the defence
Handler filters feel natural — the handler is the thing that ships the data. But by then the record may have been copied, and a second handler may already have written it.

Prerequisites

The standard library is enough. Pin a structured formatter if the service does not already emit JSON, since redaction and structure are usually adopted together.

pip install "python-json-logger>=2.0.7,<4.0.0"
export LOG_REDACT_SALT="$(openssl rand -hex 16)"   # per-deployment, not per-process

The salt must be identical across every replica of a service and rotate with your normal secret rotation. A per-process salt makes hashes uncorrelatable, which defeats the reason for hashing at all.

Implementation

Step 1 — Redact by key first, by value second. Key-based redaction — "the field called password is always masked" — has no false positives and no false negatives for the fields you name. Value-based patterns are for formats that cannot be anything else: a Bearer header, an sk- prefixed key, a PEM block, a card number that passes a Luhn check. Anything looser will eventually mask a request ID and hide the field you needed.

import hashlib
import logging
import os
import re

SECRET_KEYS = frozenset({
    "password", "passwd", "secret", "token", "api_key", "apikey",
    "authorization", "cookie", "session", "private_key", "card_number",
})

VALUE_PATTERNS = (
    re.compile(r"\bBearer\s+[A-Za-z0-9._\-]+", re.I),
    re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"),
    re.compile(r"://[^:/@\s]+:[^@\s]+@"),                  # credentials in a DSN
    re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----"),
)

_SALT = os.environ.get("LOG_REDACT_SALT", "").encode()

def fingerprint(value: str) -> str:
    """Stable across replicas, useless without the salt."""
    return "sha256:" + hashlib.sha256(_SALT + value.encode()).hexdigest()[:12]

Step 2 — Cover all four fields that carry text. This is where most implementations fall short. A log call carries its data in more than one place, and a filter that rewrites only record.msg leaves three doors open.

class RedactFilter(logging.Filter):
    """Runs on the producing thread; mutates the record in place and returns True."""

    def filter(self, record: logging.LogRecord) -> bool:
        record.msg = self._scrub(record.msg)                       # 1. the format string
        if record.args:                                            # 2. the args tuple
            if isinstance(record.args, dict):
                record.args = {k: self._by_key(k, v) for k, v in record.args.items()}
            else:
                record.args = tuple(self._scrub(a) for a in record.args)
        for key in list(vars(record)):                             # 3. structured extras
            if key in SECRET_KEYS:
                setattr(record, key, fingerprint(str(getattr(record, key))))
            elif key not in logging.LogRecord("", 0, "", 0, "", (), None).__dict__:
                setattr(record, key, self._scrub(getattr(record, key)))
        if record.exc_info and record.exc_info[1] is not None:     # 4. exception args
            exc = record.exc_info[1]
            if exc.args and isinstance(exc.args[0], str):
                exc.args = (self._scrub(exc.args[0]),) + exc.args[1:]
        return True

    def _by_key(self, key: str, value):
        return fingerprint(str(value)) if key.lower() in SECRET_KEYS else self._scrub(value)

    def _scrub(self, value):
        if not isinstance(value, str):
            return value
        for pattern in VALUE_PATTERNS:
            value = pattern.sub("[redacted]", value)
        return value

Field 2 is the one that surprises people. logger.info("auth failed for %s", token) never puts the token in record.msg — it sits untouched in record.args until the formatter interpolates it, long after a message-only filter has run and passed.

A message-only filter covers one of the four doors A single log record broken into the four attributes that can carry sensitive text. The msg attribute holds the format string, and a filter that rewrites only this is the common partial implementation. The args tuple holds every value passed for percent-style interpolation, so a token logged as a parameter sits here untouched until the formatter renders it. The record's own attribute dictionary holds structured extras added through the extra keyword, which a message-only filter never inspects. And the exception value inside exc_info holds its own arguments, which is how a connection string with an embedded password reaches the log through a driver error. A footer notes that formatted output is produced from all four, so covering only the first still emits the secret. one record, four attributes that can hold a secret 1 · record.msg "auth failed for %s" the format string — this is the one every implementation covers 2 · record.args ("sk-live-9f3c8a…",) the token is here, not in msg interpolated by the formatter, after your filter 3 · extras on the record extra={"session": "abc…", "user_id": 7} separate attributes, never in msg redact these by key name, not by pattern 4 · exc_info[1].args OperationalError("…://user:pw@db…") the driver put the DSN in the message and the traceback will print it verbatim the rendered line is built from all four — covering only the first is a filter that reports success and leaks anyway
Boxes 2 through 4 are how secrets actually reach production logs. Box 1 is the one the regex was written for.

Step 3 — Attach it to the logger, not the handler. In dictConfig, filters listed on a logger run in Logger.handle() before any handler is consulted; filters listed on a handler run inside Handler.handle(), once per handler, after the record has already been passed around.

CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {"redact": {"()": "observability.redact.RedactFilter"}},
    "formatters": {"json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter"}},
    "handlers": {
        "queue": {"class": "logging.handlers.QueueHandler", "queue": "cfg://custom.queue"},
    },
    "root": {"level": "INFO", "handlers": ["queue"], "filters": ["redact"]},
}

One caveat that catches people: filters on a logger do not apply to records propagated up from child loggers — Logger.filter() runs only on the logger the call was made on. Attach the filter to the root logger and make sure library loggers propagate to it, or attach it to each top-level logger your code uses. Verifying that is a one-line assertion worth having in a test.

Step 4 — Choose mask or fingerprint per field. [redacted] for anything you never need to correlate. A salted fingerprint for values where "the same token failed forty times in a minute" is the signal you are looking for. Never a truncation — the first six characters of an API key are enough to identify the customer and sometimes enough to guess the rest.

Mask, fingerprint, or truncate Three ways to replace a secret in a log record, compared on what they preserve and what they give away. A fixed mask replaces the value with a constant marker: nothing leaks, and nothing can be correlated, so fifty occurrences of the same token are indistinguishable from fifty different ones. A salted fingerprint replaces it with a short hash of the salt and the value: the same input always produces the same output, so occurrences can be counted and grouped, and without the deployment salt the digest cannot be reversed or compared against a precomputed table. Truncation keeps a prefix of the real value, which is the option to avoid: a key prefix usually identifies the issuing account and sometimes narrows the remaining search space, so it leaks while looking cautious. sk-live-9f3c8a2b1d4e7c60 — three ways to write it down fixed mask "[redacted]" leaks: nothing correlates: nothing 50 occurrences look identical to 50 different values salted fingerprint "sha256:1b9d4f0a77c2" leaks: nothing without the salt correlates: exactly "this token failed 50 times" is still a question you can ask truncation "sk-live-9f3c…" leaks: the issuing account correlates: partially looks cautious, is not avoid rotate the salt with your other secrets — and keep it identical across replicas, or the same token fingerprints differently per pod
Truncation is the compromise that feels responsible and is not: a key prefix is usually enough to name the account it belongs to.

Configuration options

Option Type Default Recommended
Filter placement logger / handler logger (root plus each top-level logger)
SECRET_KEYS set of names keys you own, matched case-insensitively
VALUE_PATTERNS regex tuple unambiguous formats only
Replacement mask / fingerprint mask fingerprint where correlation matters
LOG_REDACT_SALT env unset 16 random bytes, per deployment, rotated
Exception args rewrite bool off on — driver errors carry DSNs
Frame locals in tracebacks bool off keep off in production

Verification

Test the record, not the rendered line. A test that greps formatter output passes for the wrong reason the day someone changes the formatter.

import logging

def test_token_in_args_is_redacted():
    records = []
    logger = logging.getLogger("t")
    logger.addFilter(RedactFilter())
    logger.addHandler(type("H", (logging.Handler,), {"emit": lambda s, r: records.append(r)})())
    logger.setLevel(logging.INFO)

    logger.info("auth failed for %s", "Bearer sk-live-9f3c8a2b1d4e")
    assert "sk-live" not in records[0].args[0]        # the args tuple, not the message

def test_extra_key_is_fingerprinted():
    ...
    logger.info("login", extra={"session": "s_9f3c8a2b"})
    assert records[0].session.startswith("sha256:")

Expected Output:

test_redact.py::test_token_in_args_is_redacted PASSED
test_redact.py::test_extra_key_is_fingerprinted PASSED

Then confirm the end-to-end shape once, so you know the filter is actually installed in the running configuration rather than only in the test:

logging.config.dictConfig(CONFIG)
logging.getLogger("service").info(
    "connecting to %s", "postgresql://svc:hunter2@db.internal:5432/app",
    extra={"api_key": "sk-live-9f3c8a2b1d4e"},
)

Expected Output:

{"levelname": "INFO", "name": "service",
 "message": "connecting to postgresql://[redacted]db.internal:5432/app",
 "api_key": "sha256:1b9d4f0a77c2"}

Common mistakes

The filter is on the handler and one sink already wrote the record

Error signature: the JSON sink shows [redacted] and the file sink shows the secret. Root cause: handler filters run per handler, and the logger hands the same record to each in registration order. Remediation: move the filter to the logger. Keep handler filters for severity routing and other decisions that are genuinely per-sink.

Interpolated arguments slip through

Error signature: a secret appears in the rendered message although the filter clearly rewrites record.msg. Root cause: the value lives in record.args and is only merged into the message at format time. Remediation: rewrite record.args as well, handling both the tuple and the mapping form.

Redaction turns into a CPU cost on the hot path

Error signature: P99 latency rises after deploying the filter; profiles show time in re.sub. Root cause: a dozen patterns applied to every record at DEBUG volume, most of which cannot match the field being scanned. Remediation: anchor the patterns, apply value scanning only to string fields, and skip records below INFO on hot paths. The measurement technique is in measuring Python logging overhead.

Choosing what to redact

A redaction filter is only as good as its list, and the list is a policy decision rather than a technical one. Three questions decide it, and answering them once beats discovering the answer during an incident review.

What must never appear, anywhere? Credentials, tokens, private keys, full card numbers, and anything that would let a reader authenticate as someone else. These are unconditional: mask them regardless of level, environment or how convenient the value would be for debugging. This is the set that justifies a key-based rule rather than a pattern, because you can enumerate it.

What is personal data? Email addresses, names, addresses, phone numbers, and identifiers that resolve to a person. The right treatment here is usually a fingerprint rather than a mask, because support and debugging genuinely need to know that the same customer appeared in fifty records — they just do not need to know which customer from the log itself. A fingerprint plus a separate, access-controlled lookup satisfies both needs.

What is merely commercially sensitive? Internal hostnames, pricing rules, queue depths, feature flags. These usually do not need redaction in a log your own team reads, and do need it in anything exported to a vendor or a support ticket. The practical approach is to leave them in the record and handle them at the export boundary, because redacting them in-process removes information your own engineers need.

Where the list comes from

Deriving the key list from your own schema beats maintaining it by hand. If the application already has serialisation models — dataclasses, Pydantic models, an ORM — the fields marked sensitive there are the same fields that must be masked in logs, and generating the set at import time keeps the two from drifting.

from dataclasses import fields

def sensitive_keys(*models) -> frozenset[str]:
    """Every field annotated as sensitive across the given models."""
    found = set()
    for model in models:
        for f in fields(model):
            if f.metadata.get("sensitive"):
                found.add(f.name)
    return frozenset(found)

SECRET_KEYS = sensitive_keys(User, PaymentMethod, ApiCredential) | {"authorization", "cookie"}

A field added to a model with the sensitive marker is then covered by redaction the moment it exists, with no second change to remember.

Verifying the policy, not just the code

The test that matters is not "does the filter mask this string" but "can any known-sensitive value reach a sink". A property-style test that walks the model definitions and asserts that each sensitive field name is in the filter's key set catches the case the unit tests miss, which is a new field nobody thought about.

def test_every_sensitive_model_field_is_redacted():
    for model in (User, PaymentMethod, ApiCredential):
        for f in fields(model):
            if f.metadata.get("sensitive"):
                assert f.name in SECRET_KEYS, f"{model.__name__}.{f.name} is not redacted"
Category Treatment Why
Credentials and tokens mask, unconditionally no debugging value justifies the exposure
Personal identifiers salted fingerprint correlation without readability
Free-text user content mask or omit the field it can contain anything, including the two above
Internal operational data leave in, redact at export your team needs it; a vendor does not

One caveat worth stating plainly: redaction is a safety net rather than a design. The strongest control is not logging the value in the first place, and a filter that has to mask a field on every record is a hint that the field should not have been passed to the logging call at all.

Frequently Asked Questions

Should the redaction filter go on the logger or the handler?

On the logger. Filters attached to a handler run inside that handler, which may already be on the far side of a queue or in a different process, and a record that reached a second handler first has already shipped. A logger filter runs on the producing thread before any handler sees the record, which is the only placement that covers every sink.

Why does my regex miss secrets that are clearly in the output?

Almost always because it only inspects record.msg. A log call like logger.info('auth failed for %s', token) leaves the token in record.args, untouched until the formatter interpolates it. Structured extras and exception arguments are two more fields the same pattern never sees.

Is hashing better than masking?

It depends on what you need afterwards. A fixed mask is safest and unambiguous. A short salted hash keeps the value correlatable — you can still tell that the same token appeared in fifty records — at the cost of being a stable identifier. Use a per-deployment salt so hashes cannot be compared against a precomputed table.

Can I redact at the log backend instead?

You can add it there as a second layer, but not as the only one. By the time the record reaches the backend it has crossed the network, passed through a collector, and probably been written to a local buffer file. Redaction is only meaningful at the point where the data still cannot escape, which is the producing process.