Preventing Log Injection in Python
Log injection is the practice of writing attacker-controlled text into a log so that the log appears to say something it should not — most often a forged record following a genuine one, but also escape sequences that alter a terminal, or values large enough to break the pipeline. It is an old problem with an almost complete modern solution, and this page covers both the solution and the edges it leaves open. It is a task article under logging security and compliance, part of the Python logging fundamentals and structured data section.
Prerequisites
pip install "python-json-logger>=2.0.7,<4.0.0"
Implementation
Step 1 — Log one JSON object per record. A JSON formatter serialises each record as a single object, and JSON string encoding escapes newlines, carriage returns and quotes inside values. However hostile a value is, it remains a string inside one field of one record. This closes the forging form of injection completely, and it is the same change that makes logs queryable, so it rarely needs separate justification.
import logging
from pythonjsonlogger import jsonlogger
handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
Step 2 — Keep untrusted input out of the message. Even in JSON, a message built from user input blurs which part of the record the service wrote and which part the user supplied. An alert rule matching on message text can then be triggered, or evaded, by crafted input. A fixed message with input in labelled fields keeps the boundary visible and the message trustworthy.
log = logging.getLogger("auth")
# trusted template, labelled input
log.warning("login failed", extra={"username": submitted, "source_ip": ip})
# avoid: input becomes indistinguishable from template text
log.warning("login failed for %s", submitted)
Step 3 — Strip control characters from string fields. JSON escaping handles newlines; it does not remove terminal control sequences, which survive as characters inside the string. When a record is later printed to a terminal — kubectl logs, a developer tailing a file — those sequences can move the cursor, clear the screen or change colours, altering what the reader sees. Removing control characters from string values in the formatter prevents it.
import re
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x1b]")
def _clean(value: str, limit: int = 2000) -> str:
cleaned = _CONTROL.sub("�", value) # visible replacement character
if len(cleaned) > limit:
cleaned = cleaned[:limit] + f"…[+{len(cleaned) - limit} chars]"
return cleaned
Step 4 — Truncate every string field. A user-supplied value of several megabytes — a header, a form field, a query string — logged verbatim can exceed a container runtime's line limit, fill a shipper's buffer, or be rejected by the store. Truncating in the formatter, with a marker saying how much was removed, bounds the damage and keeps the fact that something large arrived.
class InjectionSafeFormatter(jsonlogger.JsonFormatter):
def add_fields(self, target, record, message_dict):
super().add_fields(target, record, message_dict)
for key, value in list(target.items()):
if isinstance(value, str):
target[key] = _clean(value)
def format(self, record):
return super().format(record).replace("\r", "")
Step 5 — Test with hostile input. The controls above are only trustworthy if they are exercised. A test that logs a value containing newlines, escape sequences and a very long string, then asserts that the output is exactly one line, parses as JSON, contains no control characters and is bounded in length, catches any regression — including one introduced by a new field that bypasses the formatter.
import io, json, logging
HOSTILE = "bob\n2026-09-18 INFO login succeeded user=admin\x1b[2J" + "A" * 50_000
def test_hostile_input_stays_one_bounded_record():
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(InjectionSafeFormatter("%(levelname)s %(message)s"))
log = logging.getLogger("inj-test"); log.handlers = [handler]; log.propagate = False
log.warning("login failed", extra={"username": HOSTILE})
lines = stream.getvalue().splitlines()
assert len(lines) == 1, "a value created a second record"
record = json.loads(lines[0])
assert "\x1b" not in record["username"]
assert len(record["username"]) < 2100
Expected Output: one record, with the hostile content neutralised and visibly truncated.
{"levelname": "WARNING", "message": "login failed", "username": "bob\n2026-09-18 INFO login succeeded user=admin�[2JAAAA…[+48047 chars]"}
Where input enters without anybody noticing
The controls apply to every string field, which matters because user input reaches log records through more routes than the obvious one.
Exception messages. A validation error that includes the offending value in its message — "invalid email: {value}" — carries user input into the exception's text, and from there into the formatted traceback. Code that carefully logs input in fields can still leak it through exceptions it did not write. Applying the cleaning function to the formatted exception, not only to extra fields, covers this.
Framework access logs. Web frameworks and servers log the request path, query string, user agent and referrer, all of which are attacker-controlled. These records are usually produced by the framework's own logger with its own formatter, so they bypass the application's controls entirely unless the framework's logging is routed through the same formatter. Configuring logging for FastAPI and Uvicorn covers bringing access logs under the same configuration.
Third-party libraries. An HTTP client logging the URL it requested, a database driver logging a failed statement, a queue library logging a message it could not process — each may include data that originated with a user. Routing all loggers through the root handler, so every record passes through the same formatter, is what ensures the controls apply regardless of which code produced the record.
Context fields. A request identifier taken from an incoming header is user input if the service accepts it without validation. Propagated identifiers deserve the same cleaning as any other string, and validating their format at the edge — a request identifier that is not a UUID is rejected and replaced — is better still.
When plain-text logs cannot be avoided
Some services must produce plain-text logs — a legacy consumer that parses a fixed line format, a syslog destination that expects a traditional message, a compliance archive defined years ago. The forging risk returns in full, and the defence shifts from structure to encoding.
The principle is to encode every user-supplied value so that it cannot contain the characters the format uses as delimiters. For a line-oriented format, that means newlines and carriage returns must never appear literally; replacing them with a visible escape such as \n preserves the information while keeping the record on one line. For a key-value format, the separator and quote characters inside values need escaping as well, or a value containing status=ok can inject a field.
Quoting every user-supplied value, and escaping quotes inside it, is the most robust general approach, because it makes the boundary of each value explicit regardless of what it contains. It is also the approach most often skipped, because the unquoted form looks tidier in the common case where values are benign.
The more durable answer is usually to produce structured records internally and render the legacy format at the edge, in one formatter written once and tested with hostile input. That confines the plain-text risk to a single, reviewable piece of code rather than distributing it across every log call in the service, and it means the rest of the pipeline can move to structured logs whenever the legacy consumer is retired.
Configuration options
| Control | Where | Cost |
|---|---|---|
| JSON formatter | handler | none beyond structured logging itself |
| Fixed message templates | call sites | a coding convention |
| Control character stripping | formatter, every string | a regex per string field |
| Field truncation | formatter, every string | negligible |
| Exception text cleaning | formatter | applies the same function to the traceback |
| All loggers through one formatter | root handler | configuration only |
| Hostile-input test | test suite | one test |
Verification
The test from step 5 is the verification, and it is worth running against the real configuration rather than a formatter built in the test — the property that matters is that production output is safe.
pytest tests/test_log_injection.py -q
Expected Output:
.
1 passed
A failure here after a change to the logging configuration usually means a new handler was added with a different formatter, bypassing the controls — which is exactly what the test exists to catch.
Common mistakes
Believing JSON logging solves everything. Error signature: terminal output corrupted when an engineer tails a log containing hostile input. Root cause: JSON escapes newlines but leaves escape sequences intact. Remediation: strip control characters in the formatter.
User input in the message. Error signature: alert rules matching on crafted input, or messages that cannot be grouped. Root cause: formatting input into the template. Remediation: fixed messages with input in fields.
Controls only on extra fields. Error signature: hostile input appearing in exception text. Root cause: cleaning applied to extras and not to the formatted traceback. Remediation: clean every string the formatter emits.
Framework logs bypassing the formatter. Error signature: access log lines containing raw, unbounded request paths. Root cause: the server's own logger configured separately. Remediation: route every logger through the same handler.
No length limit. Error signature: records rejected or a collector stalled after a very large header arrived. Root cause: values logged at any size. Remediation: truncate with a marker.
Frequently Asked Questions
What is log injection?
Writing attacker-controlled text into a log in a way that changes what the log appears to say. The classic form is a value containing a newline that, in a plain-text log, produces a second line indistinguishable from a genuine record — for example a forged successful login following a real failed one.
Does JSON logging prevent it completely?
It prevents record forging, because JSON escapes newlines inside string values and the record stays one object. It does not by itself prevent terminal escape sequences reaching a reader, very long values exhausting buffers, or a message template containing user input being misread. Those need their own handling.
Is it dangerous to use f-strings in log calls?
It mixes trusted template text with untrusted input in a single string, which makes it impossible to tell afterwards which part came from the user. It also defeats grouping by message. Passing values as fields avoids both problems.
Can log injection affect things other than humans reading logs?
Yes. Parsers that split on newlines, alert rules that match message text, and log-based metrics that count records by pattern can all be fooled or broken by crafted input. Anything that treats log text as trustworthy structure is a potential target.