Safeguard
Application Security

Your Logs Are the Least Protected Copy of Your Most Sensitive Data

Nobody writes log.info(password). The leaks come from logging whole request bodies, exception objects, header maps and serialised domain objects, into a store replicated everywhere and retained for a year.

Daniel Osei
Security Analyst
6 min read

Your logs are the least protected copy of your most sensitive data. They are replicated to an aggregator, retained for a year, queryable by everyone in engineering, shipped to a third-party SaaS, and excluded from most of the controls you built for the database.

Then somebody logs a request body during a debugging session, and a password is now in all of those places, for a year, searchable.

This post is what should never reach a log, why redaction at the aggregator does not work, and how to build logging that is useful without becoming a liability. For whoever owns the logging pipeline.

What ends up in logs by accident

Nobody writes log.info(password). The leaks come from logging composite objects that happen to contain sensitive fields.

Whole request bodies. The commonest by far. A middleware logging every request for debugging captures the login endpoint too, and now every password is in the log.

Whole exception objects. A stack trace from a database driver can include the query, and the query includes the parameters. A failed authentication carries the credential that failed.

URLs with query strings. Tokens in query parameters are a bad practice that persists, and access logs record the full path by default. A password reset link in a log is a working password reset link, usable by anyone with log access until it expires.

Headers. Authorization, Cookie, X-Api-Key. Logging the header map is one line and catches all three.

Serialised domain objects. log.debug("saving {}", user) prints whatever toString produces, which is every field, including the ones added last month by someone who did not know this log line existed.

That last one is the durable trap, because the log line is correct when written and becomes a leak later, when a field is added. Nothing flags it.

Why redaction at the aggregator is too late

The common answer is a pattern-matching rule in the log aggregator that masks things resembling secrets. It is worth having and it is not a control, for four reasons.

The data already left the process, crossed the network, and was written to local disk. Redaction happens at the end of that journey.

Pattern matching catches formats it knows: credit card numbers, some token shapes. It does not catch a password, which looks like any other string, or an internal identifier, or a person's name.

Free text defeats it. A secret inside a JSON blob inside a stack trace inside a message field is structurally invisible to a rule watching for a field name.

And it fails open. A pattern that does not match passes the data through, silently, which is the same failure shape as every other control in this series.

Redact at the source. The aggregator rule is a backstop for what the source missed.

What to do instead

Allowlist fields, never blocklist them. A blocklist protects the fields somebody remembered. An allowlist means a new field is invisible until somebody decides it should be logged, which is the correct default. This is the single highest-value change here.

// blocklist: the new field added next quarter is logged by default
log.info("user update: {}", user);

// allowlist: only what is named is emitted, ever
log.info("user update: id={} tenant={} fields_changed={}",
         user.getId(), user.getTenantId(), changedFieldNames);

Never log a value you can log an identifier for. user_id=4812 is as useful for debugging as the email address and carries none of the risk. This covers most real cases: you almost never need the value, you need to correlate.

Ban whole-object logging in review. Make it a lint rule if your language allows it. The pattern is greppable:

grep -rnE 'log\.(info|debug|warn)\([^)]*\b(request|body|user|payload|headers|params)\b\)' src/

Log that a secret was present, not what it was. For debugging a token problem you need to know whether a token arrived, its length, its prefix, and whether it parsed. None of those require the token.

auth failed: token present=true len=184 prefix=eyJhbGci alg=HS256 reason=expired

That line solves the debugging problem completely and leaks nothing.

The controls around the pipeline

Source-level discipline is the main thing. These reduce the blast radius when it slips, which it will.

Retention. Most logs are useful for days and retained for a year because that was the default. Shorter retention is the cheapest risk reduction available, and it is a configuration change.

Access. Log search is usually open to all of engineering, which is reasonable, and means your logging pipeline is a data access path with no review. At minimum, log the searches.

Third parties. Your log aggregator is a subprocessor holding whatever your logs contain. If you have not enumerated what that is, you cannot answer a data-protection question about it, and that question does get asked.

Rotation on discovery. When a credential does land in a log, rotate it. Deleting the log entry is not remediation: it was replicated, indexed, possibly backed up, and you cannot establish who read it.

The thing that makes this recurrent

The leak is almost never introduced by someone being careless about security. It is introduced during an incident, by someone adding a temporary debug log to understand a problem, under pressure, with every intention of removing it.

It does not get removed. It gets committed, reviewed by someone who sees a debug line rather than a data flow, and runs in production for three years.

So the durable fix is structural rather than educational. An allowlist logger makes the careless version impossible rather than discouraged, and that is the difference between a rule people follow and a rule people follow when they are not busy.

The concession

Aggressive redaction makes debugging harder, and that cost falls on whoever is trying to fix production at 03:00. A log line that says only auth failed: user_id=4812 is safe and sometimes insufficient.

The way through is the structured middle: log enough shape to diagnose, never the value. Length, prefix, format validity, which check failed, a correlation id. In practice this answers the majority of questions, and for the remainder, having a deliberate way to capture more detail temporarily, with approval and a short expiry, is better than logging everything permanently in case.

The implication

Logs are treated as operational exhaust and protected accordingly, while containing a growing share of what your database holds. That mismatch is the whole problem, and it widens quietly, one debug line at a time.

The fix that holds is the allowlist, because it inverts the default. Everything else here is mitigation for what gets past it.

Never miss an update

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

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.