Safeguard
Application Security

Preventing log injection in Node.js and Express

A single unescaped newline in req.body can let an attacker forge fake log entries — CWE-117 log injection still hits Node apps that log with plain string concatenation.

Safeguard Research Team
Research
6 min read

CWE-117, "Improper Output Neutralization for Logs," has a simple root cause: a log line built by concatenating untrusted input with a plain string. In an Express handler as ordinary as console.log('User login: ' + req.body.username), nothing stops an attacker from submitting a username containing a carriage-return/line-feed pair (\r\n, bytes 0x0D/0x0A). Those two bytes are enough to terminate the current log line and start a fabricated one — including a fake "admin login succeeded" entry that never happened. OWASP catalogs this as Log Injection (also called log forging), a close cousin of the CRLF-injection family that also produced HTTP response splitting in the mid-2000s. It isn't a hypothetical concern specific to homegrown loggers, either: the maintainers of log4js-node, one of the more widely used Node logging libraries, opened and discussed an issue titled "Avoid log injection / log forging" (log4js-node/log4js-node#1355) confirming that popular Node logging tooling doesn't neutralize control characters by default. This post walks through how the attack works, why it matters even when nobody reads logs by eye anymore, and the two concrete fixes — input sanitization and structured logging — that close it.

What does a log injection payload actually look like in a request?

A log injection payload is nothing more exotic than embedded control characters riding inside a field your app already logs — a username, a URL path, a header value, a query parameter. If an Express route logs `Failed login for user: ${req.body.username}` and an attacker submits a username of alice\r\n2026-07-08T00:00:00 INFO Admin login succeeded for user: admin , a naive text-based log viewer or downstream parser sees two lines: the real failed-login record and a second line that looks exactly like an authenticated success event, because the injected \r\n closed out the first entry. OWASP's CRLF Injection reference describes this same character pair as the mechanism behind classic HTTP response splitting, because CR and LF are the delimiters nearly every text-based protocol and log format uses to separate records. The attack doesn't require any special tooling — a normal HTTP client, curl, or browser dev tools is enough, since the vulnerability lives entirely in how the server formats the string before writing it, not in any client-side behavior.

Why is this dangerous even if no human reads raw logs?

It's dangerous precisely because most logs today are consumed by machines, not humans. SIEM pipelines, alerting rules, and compliance audit trails routinely parse log files line by line and pattern-match on fields like "login succeeded" or "admin action." A forged line injected via CRLF can trip a false "success" event that suppresses a real alert, pollute an audit trail that a SOC 2 or PCI auditor later reviews as evidence of who accessed what, or simply corrupt automated parsers that assume one event equals one line, silently dropping or misattributing subsequent legitimate entries. OWASP's framing of log forging specifically calls out this splitting/fabrication mechanism as the core risk, distinct from log flooding or other logging abuses — the attacker isn't trying to fill your disk, they're trying to make the log say something that didn't happen, or make your parser choke on something that did. In an incident-response scenario, a forged entry can point investigators at the wrong account or the wrong timeline at exactly the moment accuracy matters most.

How do you sanitize input before it reaches a log call?

The direct fix is to strip or escape carriage returns, line feeds, and other non-printable control characters from any user-controlled value before it's interpolated into a log message — never let raw request data dictate log line boundaries. OWASP's Logging Cheat Sheet lists this as baseline guidance: encode or remove CR/LF and other control characters, and validate or allowlist input where the field has a known shape (an email address or numeric ID shouldn't contain control characters at all). In practice for a Node/Express handler, that means running a small sanitizer — something as simple as value.replace(/[\r\n]/g, '') or a stricter allowlist regex — on any field pulled from req.body, req.query, req.headers, or req.params before it touches a log call, rather than trusting that the logging library will handle it. This is a cheap, mechanical fix, but it has to be applied consistently at every log call site, which is exactly the kind of repetitive hygiene that's easy to skip under deadline pressure and easy for a linter or static rule to catch instead.

Why does structured logging matter more than escaping alone?

Structured logging matters because it changes the data model, not just the encoding: instead of building one interpolated string, you pass an object with named fields to a logger like Pino or Winston, and the logger serializes that object to JSON. In JSON, a value containing \r\n is escaped as the literal two-character sequence \r\n inside a quoted string — it can never terminate the record or create a second line, because JSON's own grammar defines where an entry begins and ends, independent of what bytes sit inside a string value. That's a structural guarantee that string concatenation can't offer. OWASP's Logging Cheat Sheet recommends structured output specifically for this reason: it removes the entire class of delimiter-confusion bugs that CWE-117 describes, because the field boundary is no longer inferred from raw bytes in the message. The caveat is real, though — structured formatting protects the log record's shape, but if a downstream system re-renders that JSON field into a plain-text terminal, dashboard, or report without its own escaping, the same control characters can still cause visual spoofing there. Structured logging and input sanitization are complementary, not substitutes for each other.

What does a safe logging pattern look like end to end?

A safe pattern combines both layers: sanitize any user-controlled string field to strip control characters, then pass it as a named field to a structured logger rather than interpolating it into a template string. Concretely, that's logger.info({ event: 'login_failed', username: sanitize(req.body.username) }) using Pino or Winston, instead of console.log('Failed login: ' + req.body.username) . The sanitize step removes the CR/LF that would otherwise ride along even inside a JSON string value, and the structured call ensures the field is serialized rather than concatenated. Teams should also treat this as a rule to enforce automatically — a lint rule or static check that flags string-concatenated console.log/console.error calls touching req. objects catches the pattern before it ships, which is more reliable than expecting every contributor to remember it during a code review under time pressure.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.