spring-boot-gdpr

GDPR-compliant logging in Spring Boot, part 3: the personal-data leaks outside your own code

Rigas PapazisisSoftware Engineering

In part 1 we cleaned up our own log statements, and in part 2 we made a build-time test keep them clean. Both work on your source code. This post is about everything that writes personal data into your logs without passing through your source code at all: framework loggers, things that bypass the logging framework, structured side channels, and the destinations themselves.

It is also about the one switch we deliberately left open, and why writing that decision down mattered more than any code.

Tested with: Java 21, Spring Boot 4.1.0, Hibernate ORM 7.4.1.Final and Logback 1.5.34 (both managed by the Spring Boot BOM), OpenTelemetry Logback appender 2.11.0-alpha.

Start with an egress inventory

You cannot reason about leaks until you know where a log line goes. Before any rule, list every destination, who processes it, how long it is kept, and whether that retention was a decision or an accident:

# which appenders exist, and where do they write?
grep -rn "appender\|Appender" src/main/resources/logback*.xml

# anything writing outside the logging framework entirely?
grep -rn "System.out\|System.err\|printStackTrace" src/main/java

Then rank them by the one property that matters most for personal data: can a value be erased once it lands there?

EgressErasable?Notes
Container stdoutEffectively yesDies with the container; the least bad place
Rolling files on a volumeIn principle; rarely in practiceRetention set in a logging config is an unowned retention decision
Log collector or APM (OTLP, Elasticsearch, hosted services)NoA second processor; if hosted, a third-party transfer that needs a DPA
Error trackersNoOften forgotten; they carry stack traces and request context

Most teams find at least one surprise. The classic one: the documentation says there are no file appenders, and one deployable has a rolling file appender with a year of retention. Check the configuration you actually ship, not what the documents claim.

show-sql writes straight to System.out

spring.jpa.show-sql=true does not go through Logback at all. Hibernate’s SqlStatementLogger calls System.out.println directly when that flag is on, so no appender, no filter, no level and no collector configuration applies to it. Nothing can backstop it. The only control is that it stays off, in the committed configuration:

# Bypasses the logging framework entirely (System.out). Never enable it; use logging.level.org.hibernate.SQL.
spring.jpa.show-sql=false

Audit for the whole family while you are there: System.out and System.err, printStackTrace(), JVM -verbose flags, and any library with its own “debug to console” switch.

Hibernate bind-parameter logging prints every value, pre-formatted

Raise the bind logger to TRACE and Hibernate prints every value bound to every statement: national IDs, names, IBANs, whole JSON columns. Two things make it worse than it looks.

First, the value is already inside the message string. Hibernate logs through JBoss Logging’s printf-style methods (tracef("binding parameter (%s:%s) <- [%s]", …)), which format eagerly because SLF4J cannot parse %s. By the time Logback sees the event there is no argument array to inspect; only whole-message rewriting could touch it. Second, no source scanner can see it, because the value is never in your source. This one is governed by configuration and discipline, nothing else.

Know the logger names for your stack, and pin the right one. A stale name fails open: you believe you have gated something you have not.

StackBound values appear under
Hibernate 6 and 7org.hibernate.orm.jdbc.bind (statements: org.hibernate.SQL)
Hibernate 5org.hibernate.type.descriptor.sql.BasicBinder
Spring JDBCorg.springframework.jdbc.core.JdbcTemplate (statements only)
MyBatisThe mapper interface’s logger

The same applies to every diagnostic switch that turns on payload logging: SOAP or REST wire logging (Apache CXF’s LoggingFeature, an HTTP client interceptor), which writes whole envelopes verbatim, auth headers included; outbound request logging where an identifier sits in the URI, which is easy to miss because nobody thinks of a URL as a payload; and framework TRACE levels that dump request bodies. A diagnostic switch is a personal-data switch.

Structured logging has side channels

Once you export logs to a collector through OpenTelemetry, values travel outside the message text:

  • MDC. A request-scoped userEmail in the MDC is on every line of that request, and the OpenTelemetry appender exports it as an attribute if told to.
  • Key-value pairs from SLF4J 2’s fluent API, captured as attributes.
  • Exception messages in the exported stack trace: part 1’s rule 2, leaving by a second door.
  • Span attributes you set by hand.

Audit these deliberately, starting with the appender’s capture options:

<appender name="OTEL" class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
    <captureMdcAttributes>*</captureMdcAttributes>   <!-- exports EVERY MDC key, including tomorrow's -->
</appender>

A wildcard there is a standing invitation: harmless while nobody uses the MDC, a wholesale export the day someone does. Name the keys you mean to export.

A file appender is an undocumented retention policy

<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>logs/audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
    <maxHistory>365</maxHistory>   <!-- a one-year retention policy, set by a developer -->
    <totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>

If personal data can reach that file, you have a year of it, decided in a config file, owned by nobody, surviving every container restart. Either the appender goes, or the retention becomes a real policy with an owner and an entry in your record of processing activities. And check what you actually have rather than what the documentation claims.

The emergency switch we kept on purpose

Here is where we expected to add a control and ended up writing a paragraph instead. Bind-parameter logging is the most dangerous switch on the list, and it is also the one an on-call engineer genuinely needs when an incident only reproduces with real data. Two technical controls look obvious, and we decided against both.

Rejected: fail closed. A startup check that refuses to boot in production with the bind logger at TRACE. This is a product decision as much as a technical one: if the platform wants that diagnostic available in an extreme incident, a hard fail forbids exactly that.

Rejected: route it to stdout only. Keep the bind logger out of the collector, so at least the values die with the container. Tempting, but an incident severe enough to need bound values needs them where the operator can actually read them, and in a deployed environment that is the collector, not a container’s stdout.

Masking, as part 2 explained, would defeat the only reason to raise the switch. So the standing position is stated plainly: raising it prints every bound value in clear, to stdout and to the collector. The controls are a WARN default in the committed configuration, overridable per environment, and a note at the exact line an operator will touch:

logging.level.org.hibernate.SQL=${LOG_LEVEL_SQL:WARN}
# GDPR: LOG_LEVEL_SQL_BIND=TRACE prints every bound statement parameter (national IDs, names, e-mails, IBANs,
# JSON columns) IN CLEAR, to stdout AND to the OTLP collector. This is deliberate: an incident that needs bound
# values needs them where the operator can read them. There is no masking on this switch, by design.
# A value shipped to the collector cannot be erased: treat the window as reportable, plan the purge, prefer
# synthetic data, and set it back to WARN when the incident closes.
logging.level.org.hibernate.orm.jdbc.bind=${LOG_LEVEL_SQL_BIND:WARN}

The comment is the control. Whoever is about to raise the level is looking at that line, not at a wiki page. And the consequence is recorded rather than dressed up: a value in the collector cannot be erased, so raising the switch is an operational decision with a data-protection consequence, made by a person, during an incident, knowingly.

For switches you control in your own code, such as a wire-logging feature flag, add one more practice: make the switch announce itself. Log a single WARN at startup saying the instance is recording payloads, so an operator scanning a log sees it. Do not quiet that line.

The decision you must not leave to chance is which posture you have. Either a fail-closed guard, or a deliberate, announced break-glass. Not both, and not neither by accident.

Pitfalls

  • A switch you cannot raise is also a problem. Levels hardcoded in logback-spring.xml, with no property placeholder, mean no environment can raise a level without a rebuild, which makes an incident undiagnosable. Drive levels from properties with safe defaults, so the decision is an operator’s, in the open.
  • additivity="false" silently bypasses root routing. A logger declared that way does not reach the appenders you attached to root, including any you added for compliance. Such loggers are often copy-pasted from another project and name a package that does not exist in the module: inert today, one rename away from live. Delete them rather than trust them.
  • The URI is a payload. Outbound request logging on a client whose path carries an identifier is a diagnostic switch like any other, and the easiest one to leave undocumented, because nobody thinks of a URL as a payload.
  • A hosted collector is a processor, possibly a transfer. It needs a DPA, a record of processing entry, its own retention, and an answer to “can you delete one person’s data on request?”. That question is far easier to ask before the integration than after.

Write down the gaps you did not close

This is the step teams skip, and it is the one an auditor asks about. After the code fixes and the build-time test, a typical list of accepted gaps looks like this:

  1. The record-of-processing entry for the collector transfer, and for any file appender and its retention. A data-protection task, not a code one, handed to its owner.
  2. show-sql cannot be backstopped. It is false everywhere; that is the only control.
  3. The bind-parameter switch is deliberately unguarded, with the reasoning above.
  4. Any outbound client logging whose URI carries an identifier, documented beside the other switches.

Record the negative results too: the surfaces you assessed and cleared, for example no console output from database packages, no personal details in database error text, no MDC use, no hand-built span attributes, synthetic test fixtures. Writing those down means nobody re-audits them next year.

The gaps list is not a failure of the work; it is part of the work. “We know, here is why we accepted it, here is what compensates” is a defensible posture. Silence is not.

Conclusion

Your own log statements are the easy part, and parts 1 and 2 handle them. What remains is configuration: know every egress and whether it can be erased, keep the bypasses off, pin the right framework logger names, name the MDC keys you export, give every retention an owner, and decide your emergency posture before the emergency. Then write down what you did not close. That list, more than any line of code, is what makes the answer to “what personal data do your logs contain?” a sentence you can say with a straight face.

Cheers!

Related sources

Rigas Papazisis