What never to log

The list, and the reasonable code that breaks it: a generated toString, a record, an exception quoting its input, a header dump, a connection URL — each shown leaking, then fixed.

6 min read📈 Observability

Logs are the least protected copy of your data. A database has access control, encryption and an audit trail. A log line is written as plain text to a file, shipped to a platform that half the engineering organisation can search, kept for weeks, copied into support tickets and pasted into chat. Whatever reaches a log reaches all of those places.

So the question is not only what to log. It is what must never be in a log line, and — harder — how it gets there when nobody meant to put it there.

The list

Never, in any log, at any level:

  • Passwords, including failed attempts — a wrong password is usually one character away from the right one.
  • Session ids, access tokens, refresh tokens, API keys, and the Authorization and Cookie headers that carry them. A token in a log is a login for anyone who can search the logs.
  • Private keys, signing secrets, connection strings with credentials.
  • Full card numbers, CVVs, bank account numbers. PCI DSS forbids keeping a CVV after authorisation at all, and requires card numbers to be unreadable wherever they are stored — a log is storage. The last four digits are the usual safe form.
  • Government identifiers — national ID numbers, passport numbers, tax ids.
  • Health, biometric and other special-category personal data.

And by default, not without a reason you could defend: full names with addresses, dates of birth, email addresses and phone numbers, IP addresses in some jurisdictions, and request or response bodies in general. Personal data in a log is still personal data under GDPR and similar laws — subject to the same retention limits, the same deletion requests, and the same breach notification.

The OWASP Logging Cheat Sheet keeps a similar list, and it is worth reading once. The list is the easy part.

How it actually gets there

Nobody writes log.info("password=" + password). Every leak below is from code that looks reasonable, and each was run on a JVM to show exactly what reaches the log.

An object logged whole. A request class with a generated toString — the kind an IDE or Lombok's @Data gives you, which includes every field:

java
log.info("login attempt " + new LoginRequest("ana@example.com", "hunter2-correct-horse"));
plaintext
INFO login attempt LoginRequest{email=ana@example.com, password=hunter2-correct-horse}

Java records do the same. A record Credentials(String username, String password) prints every component, on Java 21:

plaintext
authenticating Credentials[username=ana, password=hunter2-correct-horse]

A value that reaches an exception message. The code logs the exception, not the data — and the exception quotes the data:

java
try {
    Long.parseLong(cardNumber);
} catch (NumberFormatException e) {
    log.warning("could not parse card: " + e);
}
plaintext
WARNING could not parse card: java.lang.NumberFormatException: For input string: "4111 1111 1111 1111"

Many JDK and library exceptions include the offending input in their message, because that is helpful when debugging. The same message becomes a leak the moment the input is sensitive. Constraint violations that include the rejected value, JSON parse errors that quote the document, and SQL errors that include bound parameters all behave this way.

Headers logged for debugging. A filter that logs every incoming request's headers, added during an investigation and never removed:

plaintext
INFO incoming request headers {Content-Type=application/json, Authorization=Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.c2lnbmF0dXJl}

That token is fake. In production it would be a working credential, valid until it expires, readable by everyone with access to the log platform.

A connection string at startup. "Connecting to …" is a friendly log line, and the URL carries the password:

plaintext
INFO connecting to postgresql://app:<the-db-password>@db.internal:5432/orders

The placeholder stands in for the real password, which is what would be there — and which this site's own secret scan refuses to let into a commit, even in a lesson about it.

The same four, fixed

The fixes are different in kind, and it helps to see them side by side:

plaintext
INFO login attempt SafeLoginRequest{email=ana@example.com, password=[REDACTED]}
WARNING could not parse card ending 1111: NumberFormatException
INFO incoming request headers {Content-Type=application/json, Authorization=[REDACTED]}
INFO connecting to postgresql://app:****@db.internal:5432/orders
  • Override toString on any class that carries a secret, so the secret is redacted wherever the object ends up — a log, an exception, a debugger. Better still, wrap secrets in a small type whose toString always says [REDACTED], so the protection travels with the value.
  • Log the exception's type, not its message, when the input may be sensitive — and log what you know is safe about the input, like the last four digits.
  • Redact by name in anything that logs headers or parameters: Authorization, Cookie, Set-Cookie, and any header or field matching password, secret, token or key.
  • Strip credentials from URLs before logging them, or better, log host and database name only.

Defence in depth

Each fix above depends on a developer remembering. The layers that do not:

  1. Structured logging with named fields. When logs are JSON with explicit fields, a redaction rule can target a field name reliably. With free text, it has to guess with a regular expression.
  2. A masking layer in the logging pipeline. Logback and Log4j2 both support patterns or converters that rewrite a message before it is written; the log shipper can apply the same rules again. These catch known shapes — card numbers, bearer tokens — and miss anything that does not match.
  3. Scanning the logs. A periodic search of the log platform for token and card-number patterns finds the leak that got past both, while it is still days old rather than years.
  4. Short retention and restricted access for the logs that might contain personal data, so a leak that does happen has a smaller blast radius.

None of those is sufficient alone. Pattern-based masking in particular produces confidence it has not earned: a password has no pattern.

What to log instead

The goal was never to log less. It was to log the things that help without the things that harm:

instead oflog
the user objectthe user id
the request bodythe request id, the endpoint, the size, and specific non-sensitive fields
the card numberthe last four digits, or a token from the payment provider
the tokenthat authentication succeeded or failed, and why, without the credential
the exception message with inputthe exception type, and a safe description of the input

An id plus a correlation id is enough to find everything else in systems that do have access control, which is exactly where the rest of the data belongs.

Progress is saved on this device and to your account when signed in.