Logging is indispensable for debugging and observability — but logs that accidentally capture credit card numbers, passwords, or Social Security Numbers can turn a routine audit into a data-breach incident. In this guide you will learn three practical techniques for masking sensitive data in Logback: a zero-code XML replacement rule, a lightweight custom MessageConverter, and a full PatternLayout override. Every approach comes with working code, a comparison table, and production-hardening tips.
Why Log Masking Matters
Regulations such as PCI-DSS, GDPR, and HIPAA require that certain data never appears in plain text in log files. Even without a legal obligation, masked logs reduce blast radius when log files are accidentally exposed. Masking should be treated as a last line of defence — not a substitute for avoiding logging sensitive data in the first place.
Common categories of data that must be masked:
- Payment card numbers (PAN) — show only the last four digits
- Social Security Numbers (SSN)
- Passwords and bearer tokens
- API keys and client secrets
- Bank account numbers
- Healthcare record identifiers
Maven Dependency
Add the Logback classic module to your pom.xml if you have not done so already:
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
Approach 1 — Built-in Replace in logback.xml (No Java Code)
Logback ships with a replace conversion word that applies a regular expression substitution directly inside the pattern. This requires zero Java code and is the quickest way to mask a single predictable pattern.
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- Replace 16-digit card numbers with a masked version -->
<pattern>
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -
%replace(%msg){"([0-9]{4})[0-9]{8}([0-9]{4})", "$1-XXXXXXXX-$2"}%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
Limitation: the built-in replace word applies only one regex. For multiple sensitive data types use Approach 2 or 3.
Approach 2 — Custom MessageConverter (Recommended)
A MessageConverter intercepts the formatted message string of every log event. It is lightweight, easy to unit-test, and supports chaining multiple masking rules.
Step 1 — Create the Converter Class
package com.ankurm.logging;
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import java.util.regex.Pattern;
public class SensitiveDataMaskingConverter extends MessageConverter {
// Matches a 16-digit card number in groups: first 4, middle 8, last 4
private static final Pattern CARD_PATTERN =
Pattern.compile("([0-9]{4})[0-9]{8}([0-9]{4})");
// Matches SSN format: NNN-NN-NNNN or NNNNNNNNN
private static final Pattern SSN_PATTERN =
Pattern.compile("([0-9]{3})[- ]?[0-9]{2}[- ]?([0-9]{4})");
// Matches key=VALUE or key: VALUE where key contains 'token','secret','password','apikey'
private static final Pattern SECRET_PATTERN =
Pattern.compile(
"(?i)(password|token|secret|apikey|api_key)(s*[=:]s*)(S+)");
@Override
public String convert(ILoggingEvent event) {
String message = event.getFormattedMessage();
if (message == null) return null;
// Chain masking operations
message = CARD_PATTERN.matcher(message)
.replaceAll("$1-XXXXXXXX-$2");
message = SSN_PATTERN.matcher(message)
.replaceAll("$1-XX-$2");
message = SECRET_PATTERN.matcher(message)
.replaceAll("$1$2*****");
return message;
}
}
Step 2 — Register the Converter in logback.xml
<configuration>
<!-- Register custom conversion word "maskedMsg" -->
<conversionRule conversionWord="maskedMsg"
converterClass="com.ankurm.logging.SensitiveDataMaskingConverter"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %maskedMsg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/application-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %maskedMsg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
Approach 3 — Custom PatternLayout (Maximum Control)
When you need to mask not just the message but also MDC values, thread names, or any other part of the formatted log line, extend PatternLayout instead:
package com.ankurm.logging;
import ch.qos.logback.classic.PatternLayout;
import ch.qos.logback.classic.spi.ILoggingEvent;
import java.util.regex.Pattern;
public class MaskingPatternLayout extends PatternLayout {
private static final Pattern CARD_PATTERN =
Pattern.compile("([0-9]{4})[0-9]{8}([0-9]{4})");
@Override
public String doLayout(ILoggingEvent event) {
// The super call formats the ENTIRE log line (including timestamp, level, etc.)
String formatted = super.doLayout(event);
return CARD_PATTERN.matcher(formatted).replaceAll("$1-XXXXXXXX-$2");
}
}
Configure it in logback.xml by replacing the <encoder> default layout class:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<layout class="com.ankurm.logging.MaskingPatternLayout">
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</layout>
</encoder>
</appender>
Unit-Testing Your Masking Logic
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SensitiveDataMaskingConverterTest {
private final SensitiveDataMaskingConverter converter =
new SensitiveDataMaskingConverter();
@Test
void cardNumberIsMasked() {
// Simulate formatted message
String input = "Payment processed with card 1234567890123456";
String result = maskMessage(input);
assertTrue(result.contains("1234-XXXXXXXX-3456"),
"Last four digits should remain visible");
assertFalse(result.contains("56789012"),
"Middle eight digits must not appear in logs");
}
@Test
void passwordKeyIsMasked() {
String input = "Connecting with password=s3cr3tP@ss";
String result = maskMessage(input);
assertFalse(result.contains("s3cr3tP@ss"));
assertTrue(result.contains("password=*****"));
}
// Helper that mimics what Logback does internally
private String maskMessage(String msg) {
ch.qos.logback.classic.spi.ILoggingEvent event =
new ch.qos.logback.classic.spi.LoggingEvent();
// In a real test use a LogCaptor or SLF4J test framework
// This simplified helper calls convert() with a stubbed message
return msg; // replace with actual invocation in your project
}
}
Approach Comparison
| Approach | Java Code Required | Scope | Performance | Best For |
|---|---|---|---|---|
| logback.xml replace | None | Message only | High | Single simple pattern |
| Custom MessageConverter | Minimal | Formatted message | High | Multiple data types |
| Custom PatternLayout | Moderate | Entire log line | Medium | MDC fields, full-line masking |
Production Best Practices
- Compile patterns once — always use
static final Patternfields, never compile inside a method that runs per log event. - Partial masking over full redaction — preserving the last four digits of a card number or the first segment of an SSN helps with troubleshooting without exposing the full value.
- Combine with async logging — pair masking with Logback’s
AsyncAppenderto keep throughput high under load. - Avoid logging sensitive data at source — masking is a safety net, not a licence to log everything and mask later.
- Audit your patterns regularly — new data formats (UUIDs used as tokens, encoded keys) can slip through outdated patterns.
- Test masking in CI — add dedicated unit tests that assert sensitive values never reach the converted output.
See Also
- Complete Guide to Logback RollingFileAppender
- Java Streams API: The Complete Reference Guide
- JUnit 6 Complete Guide
Conclusion
Masking sensitive data in Logback is straightforward once you pick the right tool for the job. Use the built-in replace conversion word for a single pattern with no code changes, a custom MessageConverter for multi-pattern masking with good performance, and a custom PatternLayout when you need full control over the entire formatted log line. Whichever approach you choose, always back it with automated tests and review your masking patterns regularly as your application evolves.