Ask a team whether their API logs personal data and the answer is usually “no, we never log the national ID”. Then read the logs. You find e-mail addresses at INFO, a search filter printed whole at DEBUG, and a customer’s identifier inside a WARN line written by a class that has no idea what it is printing.
This is the first of three posts on logging without leaking personal data. This one covers the rules that govern a single line of code. Part 2 makes those rules enforce themselves in the build, and part 3 covers the leaks that sit outside your own code entirely.
Tested with: Java 21, Spring Boot 4.1.0, SLF4J 2.0.18 and Logback 1.5.34 (both managed by the Spring Boot BOM). The rules themselves are framework-agnostic.
Not legal advice. Your data protection officer owns the assessment; this post is about what engineers can do so that the assessment comes out well.
Why a log line is a special case
Personal data in your database has a lawful basis for being there, a retention period, an erasure path and an entry in your record of processing activities. The same value in a log line has none of those. That asymmetry is the whole argument, and it maps directly onto GDPR: data minimisation (EU 2016/679 Art. 5(1)(c)) says a value not needed for diagnosis should not be written; storage limitation (EU 2016/679 Art. 5(1)(e)) makes your log retention a retention decision; the right to erasure (EU 2016/679 Art. 17) is hard to honour if one person’s data sits in a log index you cannot edit.
A value in a table row can be rectified or erased. A value shipped to a log collector effectively cannot.
National law can add more. The national GDPR implementing law in many member states puts extra conditions on processing a national identification number, which is why teams tend to protect that one field carefully. And that is exactly the failure we want to talk about.
“Personal data” is wider than one field
The most common failure is not ignoring the rule. It is implementing it for one field. A team decides “no national ID in logs”, does it carefully, and leaves in place e-mail addresses, names (including display names taken from a token), phone numbers, postal addresses, token subjects and usernames, IBANs, message subjects and bodies, free-text search terms, IP addresses, and anything health-related tied to a person, which is an Art. 9 special category.
The test to apply is simple: could this line, alone or joined with another system, single out a living person? If yes, it is personal data, whether or not it looks like an identifier.
The usual failure is not ignoring the rule. It is implementing it for one field: the national ID is handled with real care in one class, while e-mail, the most pervasive identifier of all, sits at INFO everywhere else.
Rule 1: never hand a DTO, record, entity or payload to a log statement
This is the highest-yield rule, because of one language feature: a record’s generated toString() prints every component. So does a Lombok @Data class, and so does most entity tooling. A {} placeholder with a record behind it is a promise to print every field it will ever have, including the ones added next year.
public record CustomerSearchFilter(Long customerId, String nationalId, String displayName) { }
// WRONG: prints the national ID and the name of whoever was searched for
log.debug("Customer search: filter={}", filter);
// RIGHT: which dimensions were searched on is the diagnostic; the search terms are the person's data
log.debug("Customer search: customerId={}, byNationalId={}, byName={}, page={}/{}",
filter.customerId(),
filter.nationalId() != null && !filter.nationalId().isBlank(),
filter.displayName() != null && !filter.displayName().isBlank(),
pageable.getPageNumber(), pageable.getPageSize());
Look at what the right version keeps: enough to answer “which query shape was slow, or returned nothing?”, which is the actual reason anyone wanted that line. A presence flag per dimension is almost always as useful as the value. byName=true beats name=… nearly every time.
The same goes for a request body, a queue message and an entity: log the type, the counts and the row id, never the object.
// WRONG
log.info("Received {}", request);
log.debug("Loaded {}", customer);
// RIGHT
log.info("Received order request: items={}, bytes={}", request.items().size(), rawBody.length);
log.debug("Loaded customer id={}", customer.getId());
Rule 2: never put a value into an exception message
This one is invisible until you open your global exception handler. Almost every Spring API has something like this, and ours does too:
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ProblemDetail> handle(BusinessException ex) {
log.warn("Business error [{}]: {}", ex.getErrorCode(), ex.getMessage()); // every message becomes a log line
// ... build the ProblemDetail
}
So a value in an exception message is a production log line at WARN, written from a class that cannot know what it is printing:
// WRONG: the national ID is now in the logs, written by the handler, at WARN
throw new InvalidNationalIdException("Invalid national ID: " + nationalId);
// RIGHT: the error code and the field name are the client contract; the client already knows what it sent
throw new InvalidNationalIdException(); // code = "INVALID_NATIONAL_ID"; no-arg on purpose, see the class javadoc
If you want the value “to help debugging”, that instinct is precisely what the rule exists to stop. The caller sent it, so the caller can see it. Make such exceptions no-arg and leave a comment saying why, or the next person will helpfully add the value back.
Rule 3: identify the actor by an internal account id
Log your own user-account id, never an e-mail, a username or the token’s sub claim:
// WRONG
log.info("Order {} approved by {}", orderId, currentUser.email());
log.info("Actor: {}", jwt.getSubject());
// RIGHT
log.info("Order {} approved by userAccountId={}", orderId, currentUser.userAccountId());
There are two reasons, and the second is why engineers should like this rule even without GDPR. First, an e-mail or a name is personal data, while a surrogate key is not, on its own. Second, the id is the better diagnostic. E-mails change and get reassigned; an account id is stable, and it is what your audit columns and ownership checks already key on. Logs, audit and authorization finally agree about who did something.
One clarification saves a lot of arguments in code review: a value may still flow where it is functionally the key. You can look a user up by e-mail or query by recipient address. The rule governs what is written, not what is held in a variable.
Rule 4: log shape, not content
For anything whose content is the sensitive part, log its dimensions, and make an id the thing that makes a failure traceable:
// WRONG
log.info("Sending mail to {}: subject '{}' body '{}'", recipient, subject, body);
// RIGHT: enough to trace a delivery, nothing about who or what
log.info("Notification {} queued: recipients={}, subjectChars={}, bodyChars={}, attachments={}",
notificationId, recipients.size(), subject.length(), body.length(), attachments.size());
Log the id on the success path and on the failure path. Once the addresses are gone from the log, the id is the only thing that joins a line back to the database row, and that join is available to anyone with a lawful reason to look and useless to anyone else.
This matters most in a fallback transport. The “no mail server configured, just log the message instead” provider runs exactly when an environment is misconfigured, so if it logs content it turns every notification into a permanent record of itself.
Rule 5: decide per value, and give a sometimes-personal identifier its own helper
Blanket suppression looks safe and often isn’t, because it blinds the diagnostics you need most. The instructive case is an identifier that is sometimes personal data. Take a supplier’s tax registration number. For a company it is public data, and it is the key to diagnosing every authorization decision about that supplier. For a self-employed individual, in many countries, the tax number is the person’s national identification number. Same column, same variable, two different classifications.
Hiding the field everywhere would have blinded the diagnostics. Printing it everywhere leaks a national ID. What worked was a small helper with exactly one job:
package com.example.logging;
public final class LogFormat {
/**
* Deliberately loose: anything shaped like a personal national ID is withheld, valid or not.
* NOT the validator's pattern. See NationalIdValidator, whose pattern may be tightened freely.
*/
private static final Pattern NATIONAL_ID_SHAPE = Pattern.compile("\\d{11,13}");
/**
* Prints a company registration number; withholds anything that could be a person's national ID.
* A LOGGING transform only: never use it for validation, storage or an outbound call.
*/
public static String taxIdForLog(String taxId) {
if (taxId == null) {
return null;
}
return NATIONAL_ID_SHAPE.matcher(taxId).find() ? "<personal-id-withheld>" : taxId;
}
private LogFormat() { }
}
log.warn("Supplier access refused: supplier={}, reason={}", LogFormat.taxIdForLog(supplierTaxId), reason);
Adapt the pattern to the shape of the national ID in your country. Three properties make this work, and all three generalise: it prints what is safe and useful; it is a logging transform only, so storage and outbound calls keep the raw value (say so in the javadoc, or someone will “reuse” it and corrupt your data); and it fails loose, which is the next rule. Use it at every call site that prints the identifier, not only the one you noticed: a raw value printed one field away from a line that says “person ID withheld” is easy to miss in review.
Rule 6: redaction and validation are opposite questions
When you write that helper, it will look exactly like a validator you already have. Do not call the validator.
| Question | Tolerance | |
|---|---|---|
| Validation | Is this a usable identifier? | May be tightened freely; that is its job |
| Redaction | Could this be somebody’s identifier? | Must stay loose; a mistyped ID is still personal data |
The failure is silent: someone adds a checksum to the validator, and every value it starts rejecting starts appearing in your logs. No test catches it, and no reviewer connects a validator change to a logging leak. Keep two patterns, and put a comment on each pointing at the other.
What to log instead
| Never | Log this instead |
|---|---|
E-mail, username, token sub, display name | Your internal account id |
| National ID, in any field | Nothing, or a per-value helper |
| Name, phone, postal address | The id of the row that holds it |
| IBAN | The account row’s id, or accountPresent=true |
| Recipient, subject, body | Message id, recipient count, character counts |
| Raw request or queue payload | Byte count, item count, the parse error |
A DTO, record or entity in {} | Named scalars, or a presence flag per dimension |
| Free-text search terms | Which filters were used (byName=true) |
| A URI with an identifier in the path | The route template plus the id, if the id is safe |
The general move: replace the value with the identifier of the row that legitimately holds it.
Pitfalls
- Fixing the instance, not the pattern. An audit that fixes the filter records it found carrying a national ID and a name, and stops, leaves the next one in place: another filter record, with a field nobody classed as personal data, still handed whole to
{}. Fix the class of bug, “a record handed to{}“, and let the build-time check in part 2 find the instances. - The comment that guards the line is not the line. A careful
// never log the filter itselfabove a statement protects that statement only. The next person writing a log line in another class has not read it. - Overriding
toString()is a good second line, not a licence. On a record that carries personal data, an override that prints presence flags makes the safe rendering the default. Do it, but still do not pass the record to a logger: the next component someone adds will not be in your override’s thinking, and a reviewer cannot tell from the call site which records are safe. - The throwable argument.
log.error("Failed", ex)printsex.getMessage()and every cause’s message. Rule 2 covers your exceptions; a third-party exception that quotes its input (a parser echoing the bad value, a constraint violation echoing the row) needs catching and rethrowing without the cause’s message where it matters. - Level is not a control. “It’s only at DEBUG” means it is one environment variable away from INFO, usually during an incident, which is exactly when it is shipped somewhere permanent.
Conclusion
Three rules catch almost every real leak: no object handed to {}, no value in an exception message, and an internal account id for the actor. The other three, shape over content, a per-value helper for the identifier that is sometimes a person’s, and keeping redaction apart from validation, handle the cases where blanket rules would cost you diagnostics. None of them makes your logs less useful; most make them more joinable.
But a rule that lives in prose holds only where its author was looking. In part 2 we turn these rules into a failing build, and in part 3 we look at the leaks no code review will ever see.
Cheers!
Related sources
- Regulation (EU) 2016/679 (GDPR), Articles 5, 9 and 17
- Java Language Specification 21, §8.10.3: record members, including the implicit
toString() - Spring Boot reference: Logging
- OWASP Logging Cheat Sheet: data to exclude
- Part 2: a build-time guard · Part 3: the leaks outside your code
- GDPR-compliant logging in Spring Boot, part 3: the personal-data leaks outside your own code - October 19, 2026
- GDPR-compliant logging in Spring Boot, part 2: a build-time test that fails on personal data in log statements - October 19, 2026
- GDPR-compliant logging in Spring Boot, part 1: what must never reach a log line - October 19, 2026

