Logging Security and Compliance in Python
A log store is a database that nobody designed as one. It accumulates user input that was never validated, personal data that was never classified, and occasionally secrets that were never meant to leave memory — and it is typically readable by far more people than the production database it mirrors. This guide covers the security and compliance properties that follow: records that cannot be forged, data that is minimised before it is written, audit trails that are trustworthy, and retention that follows obligations rather than storage budgets. It is part of the Python logging fundamentals and structured data section, and it builds on redacting sensitive data in log records.
Prerequisites
pip install "python-json-logger>=2.0.7,<4.0.0" \
"structlog>=24.1.0,<26.0.0"
Concept and architecture
Four distinct risks live in a logging pipeline, and they are easy to conflate because they all involve "sensitive things in logs".
Injection is about integrity. User-controlled text written into a log can forge records, corrupt parsing, or carry markup that a log viewer renders. The classic form is a newline in a username producing a second, fake line in a plain-text log — convincing to anybody reading the file. The structural fix is structured logging: a value serialised as a JSON string field cannot create a new record however many newlines it contains. Preventing log injection in Python covers the remaining edges.
Exposure is about confidentiality. Logs collect personal data and secrets by accident: a request body logged for debugging, an exception message containing a connection string, a user object serialised whole. The fix is to decide at the source what may be logged — identifiers rather than data, allow-lists rather than deny-lists — and to enforce it in the formatter where it cannot be bypassed. Logging personal data safely covers the approach.
Evidence is about trustworthiness. Some records exist to prove what happened: who changed a permission, who exported data, who approved a payment. These need to be complete, ordered and tamper-evident, and they have a retention period that someone outside engineering specifies. Operational logs, by contrast, can be sampled and discarded. Audit logging in Python applications covers building the separate path.
Access and retention are about the store. Logs frequently contain the same information as the production database, so access to them is access to that information, and retaining them longer than necessary extends the exposure. These are controlled at the store, and the application's contribution is to classify records so the store can apply different rules to different classes.
Step-by-step implementation
Step 1 — Log structured, always. A JSON formatter with every variable in its own field removes the forging form of injection at a stroke: a newline inside a string field is escaped, and the record remains one object. This is the single highest-value security change available to most Python services' logging, and it is the same change that makes logs queryable.
Step 2 — Keep user input out of the message template. A message built by string formatting mixes trusted template text with untrusted input. Passing user input as a separate field keeps the message a fixed, trustworthy string and the input clearly labelled as input.
# the template is trusted; the input is a labelled field
log.warning("login failed", extra={"username": submitted_username, "ip": client_ip})
# not this — the input becomes part of the message text
log.warning(f"login failed for {submitted_username} from {client_ip}")
Step 3 — Allow-list what may be logged about sensitive objects. Logging a user, a request or a payment object whole is how personal data and secrets enter logs. A small function per sensitive type that returns only the fields known to be safe — an identifier, a status, a count — makes the safe path the easy one.
def loggable_user(user) -> dict:
"""The fields of a user that may appear in logs. Nothing else."""
return {"user.id": user.id, "user.tier": user.tier, "user.created_year": user.created.year}
log.info("export requested", extra=loggable_user(request.user))
Step 4 — Redact at the formatter as a safety net. Allow-lists prevent the common case; a redaction filter catches what gets past them — a secret in an exception message, a token in a URL, a card number in a free-text field. It runs on every record, on every field including the message and the formatted traceback, and it is tested as described in log testing and verification.
Step 5 — Route audit records separately. A dedicated logger for audit events, with propagation disabled and its own handler, gives those records their own destination, integrity controls and retention. They never mix with debug output, never get sampled away, and can be kept for years without keeping everything else for years.
audit = logging.getLogger("audit")
audit.propagate = False # never into the operational stream
audit.addHandler(audit_handler) # its own destination and retention
audit.info("permission granted", extra={
"actor.id": admin.id, "subject.id": target.id,
"permission": "billing.export", "reason": reason_code})
Step 6 — Classify records so the store can apply different rules. A field such as record.class with a few values — operational, security, audit — lets the collector route and the store retain each class appropriately. The application knows what a record is; nothing downstream can infer it reliably.
Configuration reference
| Control | Where | Protects against |
|---|---|---|
| JSON formatter, values in fields | formatter | record forging, parser breakage |
| Fixed message templates | call sites | input masquerading as trusted text |
| Allow-list per sensitive type | helper functions | personal data and secrets entering records |
| Redaction filter on every field | logger or handler filter | what gets past the allow-list |
| Separate audit logger, no propagation | configuration | audit records sampled or mixed |
record.class field |
formatter | wrong retention and access downstream |
| Store access control | log store | over-broad reading of sensitive data |
| Scheduled deletion per class | log store | over-retention |
Async and concurrency considerations
Security controls in the logging path run on whichever thread or task emits the record, and two consequences follow.
The first is that redaction must be reliable under concurrency. A filter that uses shared mutable state — a cache of compiled patterns rebuilt on configuration change, a counter of redactions — needs to be safe when many threads call it at once. Compiled regular expressions are safe to share; a dictionary being rebuilt while another thread reads it is not. Building the redaction state once at startup and treating it as immutable avoids the problem entirely.
The second concerns context. Audit records usually need to name the actor — the user or service performing the action — and that identity typically lives in a context variable set by authentication middleware. In asyncio, context variables are per task, so an audit record emitted from a task spawned by the request inherits the actor correctly. A record emitted from a thread pool worker does not, unless the context was propagated when the work was submitted. An audit record with a missing or wrong actor is worse than no record, because it is evidence that says the wrong thing; passing the actor explicitly as a field, rather than relying on ambient context, removes the risk. The broader mechanics are in using contextvars for request tracing.
Queue-based handlers, used to keep logging off the request path, format records on the listener thread. That is fine for redaction implemented in the formatter, and it means a filter attached to the queue handler — rather than to the eventual output handler — runs on the calling thread before the record is queued. Placing redaction before the queue means a record containing a secret never sits unredacted in the queue's memory, which matters if process memory is ever dumped for debugging.
Production code examples
A formatter that combines the controls above — structured output, a record class, and redaction applied to every string value including the message and traceback:
# secure_logging.py
import logging
import re
from pythonjsonlogger import jsonlogger
REDACT_PATTERNS = [
(re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._\-]+"), r"\1[REDACTED]"),
(re.compile(r"\b(?:\d[ -]*?){13,19}\b"), "[REDACTED-PAN]"),
(re.compile(r"(?i)(password|passwd|secret|api[_-]?key)=([^&\s]+)"), r"\1=[REDACTED]"),
(re.compile(r"postgres(?:ql)?://[^:\s]+:[^@\s]+@"), "postgresql://[REDACTED]@"),
]
FORBIDDEN_KEYS = {"password", "token", "authorization", "card_number", "ssn"}
def _scrub(value: str) -> str:
for pattern, replacement in REDACT_PATTERNS:
value = pattern.sub(replacement, value)
return value
class SecureJsonFormatter(jsonlogger.JsonFormatter):
def add_fields(self, target, record, message_dict):
super().add_fields(target, record, message_dict)
target.setdefault("record.class", getattr(record, "record_class", "operational"))
if record.exc_info:
target["exception"] = self.formatException(record.exc_info)
target.pop("exc_info", None)
for key in list(target):
if key.lower() in FORBIDDEN_KEYS:
target[key] = "[REDACTED]" # 1. dangerous names, whatever the value
elif isinstance(target[key], str):
target[key] = _scrub(target[key]) # 2. dangerous values, whatever the name
def format(self, record):
# 3. One line, always — no forging through embedded newlines.
return super().format(record).replace("\n", "\\n").replace("\r", "")
Expected Output: a record whose secrets are gone from the message, the fields and the traceback alike.
{"message": "upstream auth failed for Bearer [REDACTED]", "levelname": "ERROR", "record.class": "security", "authorization": "[REDACTED]", "dsn": "postgresql://[REDACTED]@db.internal/orders", "exception": "Traceback (most recent call last):\\n ...\\nConnectionError: password=[REDACTED] rejected"}
Logs as an attack surface
Beyond what logs contain, the logging path itself can be used against a service, and three forms are worth defending against explicitly.
Volume as denial of service. An attacker who can trigger a log statement — a failed login, a malformed request, a validation error — can often trigger it at high rate. Each record costs CPU to format, I/O to write, and money to ship and store. A login endpoint that logs every failure with the full request context can be turned into an expensive and noisy flood by anyone with a script. Rate limiting at the source, as in rate limiting and sampling noisy loggers, bounds the cost; the one adjustment for security events is to aggregate rather than drop, so the fact that ten thousand failures occurred survives even when the individual records do not.
Size as denial of service. A single user-supplied value of several megabytes, logged verbatim, can exceed line limits, fill buffers and stall a collector. Truncating every string field to a bounded length in the formatter — with a marker indicating that truncation happened — removes this without losing the fact that something large arrived.
Content targeting the reader. Log viewers render text, and some render it as rich content. A value containing markup or terminal escape sequences can alter how a record appears to the engineer reading it, hide adjacent records, or in poorly built viewers execute in the reader's browser. Structured output with escaping handles the first two; the third is the viewer's responsibility, and it is worth knowing which viewers a team uses and whether they escape field values.
In each case the defence is in the formatter, because it is the one place every record passes through and the last place the application controls. A formatter that escapes, truncates and redacts consistently is the logging equivalent of input validation at the edge of a service.
Proving the controls work
Compliance is ultimately about demonstration: being able to show that the controls exist and function. Three pieces of evidence cover most requests, and all three are cheap to produce if planned for.
Tests that exercise redaction. A test that logs known fake secrets through every path — message, fields, exceptions — and asserts they are absent from the output is both a regression guard and a demonstration. Its existence, and its passing history in continuous integration, answers the question "how do you know secrets do not reach the logs" more convincingly than any policy document.
A record-class inventory. A short document, generated if possible from the code, listing each record class, its destination, its retention period and who can read it. This is the artefact an auditor asks for first, and producing it from the logging configuration rather than writing it by hand keeps it accurate.
Deletion that can be shown to run. Retention policies are claims until deletion is observed. A metric or log line from the lifecycle job recording what it deleted and when turns the claim into evidence, and an alert when the job fails to run keeps it true.
Where logging meets obligations
Engineering teams often treat compliance requirements as someone else's concern until an audit or a data subject request makes them urgent. Three obligations commonly touch logging directly, and designing for them in advance is much cheaper than retrofitting.
Data minimisation. Data protection rules generally expect that personal data is collected only as far as necessary. Logs that capture whole request bodies, full user profiles or free-text fields collect far more than any operational purpose needs. Logging identifiers rather than data satisfies most operational needs — an engineer can look up the user from the identifier, under the database's own access controls — and it shrinks the log store's exposure to almost nothing.
Access and deletion requests. A person may ask what data is held about them or ask for it to be deleted. If logs contain their email address in free text across a year of records, answering either request is a search across the whole log store and deletion may be impractical. If logs contain only an opaque identifier, the request reduces to the systems that map identifiers to people. This is one of the strongest practical arguments for identifier-only logging.
Retention limits. Keeping logs for longer than their purpose requires extends exposure without benefit. Operational logs have a purpose measured in days; security logs in months; audit logs for whatever period the applicable rule states. A retention policy per record class, enforced by scheduled deletion, is what turns a statement of intent into something that can be demonstrated. Log retention and tiering strategy covers the storage side.
None of these requires legal expertise to implement; each requires the application to classify and minimise what it logs, which is exactly the set of controls in this guide.
Common mistakes
Formatting user input into the message. Error signature: forged records in plain-text logs and messages that cannot be grouped. Root cause: f-strings or % formatting with untrusted values. Remediation: fixed templates, input in fields.
Logging whole objects. Error signature: email addresses, tokens and addresses discovered in the log store. Root cause: serialising a user or request object for convenience. Remediation: an allow-list helper per sensitive type.
Redaction only in the collector. Error signature: secrets found on node disks and in collector buffers. Root cause: the record left the process unredacted. Remediation: redact in the application; keep the collector rule as a second layer.
Audit records in the operational stream. Error signature: audit events missing because the operational pipeline sampled or dropped them. Root cause: one path for records with different requirements. Remediation: a dedicated audit logger with its own destination.
Relying on ambient context for the actor. Error signature: audit records naming the wrong user or none. Root cause: context not propagated into a worker thread. Remediation: pass the actor explicitly as a field.
Unbounded field lengths. Error signature: a collector stalled or records rejected after a request with a very large header or body. Root cause: user-supplied values logged verbatim at any size. Remediation: truncate every string field in the formatter, with a marker.
Unrestricted log access. Error signature: broad read access to logs containing data that the source database restricts. Root cause: logs treated as operational tooling rather than as a data store. Remediation: control access to the log store as you would to the data it contains.
Frequently Asked Questions
Can log injection actually cause harm?
Yes. A user-supplied value containing a newline can forge a second, fake record in a plain-text log, which misleads anyone investigating and can hide an attack. Values containing control characters or markup can break parsers or, in some log viewers, execute in the reader's browser. Structured JSON logging with every user value in its own field removes the forging risk entirely.
Is it acceptable to log personal data?
Sometimes, with care. Data protection rules generally allow processing personal data for security and operations when it is necessary and proportionate, retained for a limited time and protected appropriately. The practical default is to log identifiers rather than the data itself, and to justify each exception.
What makes an audit log different from ordinary logs?
Its purpose is evidence, so it must be complete, tamper-evident and retained for a defined period. Ordinary operational logs can be sampled, dropped under load and deleted after days. Mixing the two forces one set of requirements onto the other, which is either expensive or non-compliant.
Where should redaction happen?
As close to the source as possible — in the formatter or a logging filter inside the application. Once a secret has left the process it may already be on disk on a node, in a collector's buffer, and in a backup. Collector-side redaction is a useful second layer, not the first.
Who should have access to production logs?
The same people who would have access to the data the logs contain. If logs can hold customer identifiers, email addresses or request payloads, access to the log store is access to that data and should be controlled and audited accordingly.