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.

One input, two encodings A login attempt submits a username containing a newline followed by text formatted to look like a successful login record for an administrator. In a plain-text log built with string formatting, the newline ends the genuine failed-login line and the remainder appears as a second, complete line that reads as a successful administrator login at the same timestamp. Anyone reading the file, and any parser splitting on newlines, sees two records. In a JSON log with the username in its own field, the newline is escaped inside the string value, the record remains a single object, and the hostile content is plainly visible as the contents of the username field. The note records that the JSON version also preserves the fact that the attempt happened, with the attacker's input labelled as input. username = "bob\n… INFO login succeeded user=admin" plain text, f-string 2026-09-18 10:02:11 WARNING login failed user=bob 2026-09-18 10:02:11 INFO login succeeded user=admin two records — the second one is forged, and it is indistinguishable from a real one JSON, value in a field {"level":"WARNING","message":"login failed","username":"bob\n… INFO login succeeded user=admin"} one record — the hostile content is visibly the value of the username field and the attempt itself is preserved, labelled as input
The same input forges a record in plain text and is harmlessly contained in JSON. Structured logging fixes this class of problem structurally, not by filtering.

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]"}
What each control closes Four forms of log injection are paired with the control that addresses each. Record forging through embedded newlines is closed by JSON serialisation, which escapes newlines inside string values. Template confusion, where user input appears as part of the message and can match or evade alert rules, is closed by keeping input in separate fields with a fixed message. Terminal manipulation through escape sequences, which JSON escaping leaves intact as characters, is closed by stripping control characters from string values. Resource exhaustion through very large values is closed by truncating every string field to a bounded length. The note records that structured logging alone closes only the first of the four, which is the most famous but not the only one. four forms, four controls forged records via newlines JSON serialisation escapes them input posing as template text fixed messages, input in fields terminal escape sequences strip control characters oversized values truncate every string field structured logging alone closes the first row — the famous one, and only one of four
JSON logging solves the best-known form of injection. The other three need a few lines each in the same formatter.

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.

Where untrusted input enters a record A table of places untrusted input reaches a log record in a Python web service and how to neutralise each. Request paths and query strings, logged by access loggers: record them as fields in JSON so newlines are escaped. User-Agent and other headers: truncate and record as fields. Exception messages that include user input, such as a failed parse: the formatter's JSON encoding escapes them, but plain-text formatters do not. Usernames on failed logins: fields, never interpolated into the message. Filenames from uploads: fields, with control characters stripped. The note says the rule is the same everywhere: untrusted text goes in a field of a JSON record, never into a plain-text line. entry point neutralise by request path, query string JSON field — newlines escaped User-Agent, other headers truncate, record as a field exception messages JSON formatter; plain text is unsafe usernames on failed login field, never in the message uploaded filenames field, control characters stripped untrusted text goes in a field of a JSON record — never into a plain-text line
Injection needs untrusted text inside a line format. JSON fields escape it wherever it comes from.

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.