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.
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
AuthorizationandCookieheaders 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:
log.info("login attempt " + new LoginRequest("ana@example.com", "hunter2-correct-horse"));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:
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:
try {
Long.parseLong(cardNumber);
} catch (NumberFormatException e) {
log.warning("could not parse card: " + e);
}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:
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:
INFO connecting to postgresql://app:<the-db-password>@db.internal:5432/ordersThe 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:
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
toStringon 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 whosetoStringalways 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 matchingpassword,secret,tokenorkey. - 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:
- 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.
- 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.
- 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.
- 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 of | log |
|---|---|
| the user object | the user id |
| the request body | the request id, the endpoint, the size, and specific non-sensitive fields |
| the card number | the last four digits, or a token from the payment provider |
| the token | that authentication succeeded or failed, and why, without the credential |
| the exception message with input | the 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.