CWE-201: Insertion of Sensitive Information Into Sent Data - Java
Overview
In Java applications, CWE-201 usually appears when sensitive information reaches an HTTP response, an error message, or an exception stack trace. Spring Boot, Jakarta EE, and Hibernate all provide controls for this, but misconfiguration and unfiltered exception handling still leak passwords, tokens, internal paths, PII, and database connection strings.
Java applications are particularly susceptible when JPA/Hibernate entities are directly serialized to JSON (exposing all database columns including sensitive fields), when exception stack traces are returned in HTTP responses, when toString() methods on domain objects include sensitive data, or when logging frameworks like Log4j/SLF4J log sensitive information without filtering. Jackson serializes every readable property unless told otherwise, and an error response carries a stack trace once Spring Boot's error properties are relaxed for debugging or an explicit handler bypasses them.
Common scenarios include: returning @Entity classes directly from REST controllers without DTOs, using @ResponseBody with domain objects, exposing full exception messages in production, logging authentication credentials, and including database schema information in error responses. Frameworks provide @JsonIgnore and security configuration, but nothing is hidden until someone applies them.
Primary Defence: Use Data Transfer Objects (DTOs) or @JsonView to control field exposure instead of serializing entities directly; implement a global @ControllerAdvice exception handler that extends ResponseEntityExceptionHandler, returning generic messages to the client while logging full detail server-side; keep sensitive values out of what is logged by overriding toString() on request and domain objects, with Logback converters as a backstop for both the message and the throwable; and secure Spring Boot Actuator endpoints with authentication.
A Logback Filter is not the mechanism for redaction, though it is often reached for first: decide() returns ACCEPT, DENY or NEUTRAL and has no way to alter the event, so a filter can suppress a log line but cannot rewrite one. The secure logging section below uses converters for that reason.
Common Vulnerable Patterns
Direct Entity Serialization in REST Controllers
// VULNERABLE - Exposing entire JPA entity including sensitive fields
import org.springframework.web.bind.annotation.*;
import jakarta.persistence.*;
import lombok.Data;
@Entity
@Table(name = "users")
@Data // Generates getters/setters for ALL fields
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String passwordHash; // SENSITIVE!
private String resetToken; // SENSITIVE!
private String apiKey; // SENSITIVE!
private Boolean isAdmin; // INTERNAL!
private String internalNotes; // INTERNAL!
}
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserRepository userRepository;
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
// Returns ALL entity fields to the client!
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
}
}
// Attack result - JSON response:
// {
// "id": 123,
// "username": "john",
// "email": "john@example.com",
// "passwordHash": "$2a$10$N9qo8uLOickgx2ZMRZoMye...", ← EXPOSED!
// "resetToken": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ← EXPOSED!
// "apiKey": "sk-1234567890abcdef", ← EXPOSED!
// "isAdmin": true, ← INTERNAL INFO!
// "internalNotes": "VIP customer, give special access" ← INTERNAL INFO!
// }
Why this is vulnerable: Returning the JPA entity makes the database schema the API contract, so a migration changes the response without anyone touching the controller. Jackson serialises every readable property it finds, which means a new column ships to clients the moment it is mapped.
Annotating fields with @JsonIgnore is the usual repair and is a denylist: it protects the fields somebody remembered, and the next sensitive column is exposed by default. A separate response type - a DTO or a record - inverts that, so exposure requires a deliberate line of code. It also avoids serialising lazy associations, which is the other way entities leak: touching a proxy during serialisation pulls related rows into the response.
Exception Stack Traces in HTTP Responses
// VULNERABLE - Exposing internal paths, database details, and code structure
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import java.io.StringWriter;
import java.io.PrintWriter;
@RestController
public class DataController {
@PostMapping("/api/process")
public ResponseEntity<?> processData(@RequestBody DataRequest request) {
try {
// Some database operation
return ResponseEntity.ok(performDatabaseOperation(request));
} catch (Exception e) {
// Exposes full stack trace with file paths, line numbers, SQL
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
e.printStackTrace(pw);
return ResponseEntity.status(500).body(Map.of(
"error", e.getMessage(),
"type", e.getClass().getName(),
"stackTrace", sw.toString() // DANGEROUS!
));
}
}
}
// Attack result when SQL error occurs:
// {
// "error": "ERROR: password authentication failed for user \"admin\"",
// "type": "org.postgresql.util.PSQLException",
// "stackTrace": "org.postgresql.util.PSQLException: ERROR: password authentication...
// at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(...)
// at com.mycompany.myapp.DataService.performDatabaseOperation(DataService.java:145)
// at com.mycompany.myapp.DataController.processData(DataController.java:67)
// Database URL: jdbc:postgresql://10.0.1.50:5432/production_db?user=admin&password=secret123
// ..."
// }
// ← Exposes internal paths, database credentials, IP addresses, code structure!
Why this is vulnerable: A Java stack trace names the framework, its version, every filter and proxy in the call path, and the absolute paths of the deployment. That is a map of what to attack next, and it survives fixing whatever threw.
Spring Boot's defaults are worth knowing here, because the code often is not the cause. server.error.include-stacktrace defaults to never and include-message to never on current versions, so a deployment leaking traces has usually had them turned on for debugging and left on - or is returning them from an explicit @ExceptionHandler like this one, which bypasses the setting entirely.
Detailed Error Messages for User Enumeration
// VULNERABLE - Enables user enumeration and reveals password validation logic
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
public class AuthController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@PostMapping("/api/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername());
// Reveals whether username exists
if (user == null) {
return ResponseEntity.status(404).body(Map.of(
"error", "No user found with username: " + request.getUsername() // USER ENUMERATION!
));
}
// Reveals password validation details
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity.status(401).body(Map.of(
"error", "Invalid password for user: " + request.getUsername(),
"passwordHash", user.getPasswordHash(), // EXPOSES PASSWORD HASH!
"hint", "Password must be at least 8 characters with special characters"
));
}
return ResponseEntity.ok(Map.of("token", generateToken(user)));
}
}
// Attack result for enumeration:
// POST /api/login {"username": "admin"}
// Response: "No user found with username: admin"
//
// POST /api/login {"username": "john"}
// Response: "Invalid password for user: john"
// ← Attacker now knows "john" exists but "admin" doesn't!
Why this is vulnerable: Distinguishable failures make the login endpoint answer a question it was never meant to answer - whether an account exists - and that list is what makes a credential-stuffing run worth aiming here.
Matching the message is only part of it. The status code, the response time and the presence of a Set-Cookie header are all observable, so an implementation that returns the same text but skips the password hash for an unknown user still discloses the answer through timing. Verifying against a dummy hash on the unknown-user path is what removes that difference.
Sensitive Data in Application Logs
// VULNERABLE - Logging sensitive data that can be accessed in log files
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
@RestController
public class PaymentController {
private static final Logger logger = LoggerFactory.getLogger(PaymentController.class);
@PostMapping("/api/payment")
public ResponseEntity<?> processPayment(@RequestBody PaymentRequest request) {
// Logs sensitive payment information
logger.info("Processing payment: {}", request); // LOGS CREDIT CARDS!
// Logs user credentials
logger.debug("User: {}, Password: {}",
request.getUsername(), request.getPassword()); // DANGEROUS!
try {
PaymentResult result = chargeCard(
request.getCardNumber(),
request.getCvv()
);
return ResponseEntity.ok(result);
} catch (Exception e) {
// Logs full request with sensitive data
logger.error("Payment failed for request: {}", request, e); // LOGS SENSITIVE DATA!
return ResponseEntity.status(500)
.body(Map.of("error", "Payment processing failed"));
}
}
}
@Data
class PaymentRequest {
private String username;
private String password;
private String cardNumber;
private String cvv;
private BigDecimal amount;
// Default toString() exposes all fields in logs!
}
// application.log will contain:
// INFO: Processing payment: PaymentRequest(username=john, password=secret123,
// cardNumber=4111111111111111, cvv=123, amount=99.99)
// ← All sensitive data exposed in log files!
Why this is vulnerable: A log entry outlives the request and travels further than the response ever does - to an aggregator, an index, a replica and a retention window chosen by operations. Access to that pipeline is usually broader than access to the database the data came from.
The recurring cause is logging an object rather than a field. toString() on an entity or a request DTO renders whatever it currently holds, so the leak is introduced by a later change to a class nobody thought of as security-relevant. Lombok's @ToString makes that automatic, and its @ToString.Exclude is the denylist that has to be maintained alongside every new field.
Configuration Exposure Through Actuator
# VULNERABLE - Exposing configuration and secrets via Spring Boot Actuator
spring.datasource.url=jdbc:postgresql://db.internal.com:5432/production
spring.datasource.username=admin
spring.datasource.password=SuperSecret123!
management.endpoints.web.exposure.include=*
management.endpoint.env.show-values=ALWAYS
# JWT secret in plain text
jwt.secret=my-super-secret-key-12345
# AWS credentials
aws.access.key.id=AKIAIOSFODNN7EXAMPLE
aws.secret.access.key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Attack: GET /actuator/env
# Response exposes ALL environment variables and properties:
# {
# "spring.datasource.url": "jdbc:postgresql://db.internal.com:5432/production",
# "spring.datasource.username": "admin",
# "spring.datasource.password": "SuperSecret123!", ← EXPOSED!
# "jwt.secret": "my-super-secret-key-12345", ← EXPOSED!
# "aws.access.key.id": "AKIAIOSFODNN7EXAMPLE", ← EXPOSED!
# ...
# }
Why this is vulnerable: Two settings combine here and each is individually defensible. management.endpoints.web.exposure.include=* publishes every endpoint including /env, /configprops and /heapdump; management.endpoint.env.show-values=ALWAYS removes the sanitisation that would otherwise mask anything whose key looks like a secret. Either alone is survivable, and together they publish the configuration verbatim.
The sanitisation being key-based is the reason not to rely on it after turning exposure on. Spring masks values whose keys match patterns like password, secret and key, so a credential stored under datasource.pw or integration.token.value is returned in full. /heapdump needs no key at all - it hands over the process memory, where the same values sit as strings.
Secure Patterns
DTO Pattern with Explicit Field Control
// SECURE - Using Data Transfer Objects to control exposed fields
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.Builder;
// JPA Entity - stays in service layer
@Entity
@Table(name = "users")
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String passwordHash; // NEVER expose
private String resetToken; // NEVER expose
private String apiKey; // NEVER expose
private Boolean isAdmin; // Internal only
}
// DTO for API responses - only safe fields
@Data
@Builder
public class UserDTO {
private Long id;
private String username;
private String email;
// NEVER include: passwordHash, resetToken, apiKey, isAdmin
// Factory method to convert from Entity
public static UserDTO fromEntity(User user) {
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.build();
}
}
@RestController
@RequestMapping("/api")
public class UserController {
@Autowired
private UserRepository userRepository;
@GetMapping("/user/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
// Convert to DTO - only returns safe fields
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
@GetMapping("/users")
public ResponseEntity<List<UserDTO>> getAllUsers() {
List<User> users = userRepository.findAll();
// Convert all entities to DTOs
List<UserDTO> userDTOs = users.stream()
.map(UserDTO::fromEntity)
.collect(Collectors.toList());
return ResponseEntity.ok(userDTOs);
}
}
Why this works: The DTO is an explicit allowlist: a new entity field reaches a client only when someone adds it to UserDTO and to fromEntity. The response type stops tracking the database schema, so a migration that adds a sensitive column changes nothing about what the endpoint returns.
Global Exception Handling with Generic Error Responses
// SECURE - Centralized exception handling with generic user errors
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.dao.DataAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// Standardized error response. errorCode describes the caller's situation,
// never ours - see the note below on what must not go in it.
public record ErrorResponse(String error, String errorCode, long timestamp) {
public ErrorResponse(String error, String errorCode) {
this(error, errorCode, System.currentTimeMillis());
}
}
// Global exception handler.
//
// Extending ResponseEntityExceptionHandler is what keeps Spring's own
// client-error exceptions at their proper status. A bare @ControllerAdvice
// with @ExceptionHandler(Exception.class) also catches
// HttpRequestMethodNotSupportedException, HttpMediaTypeNotSupportedException,
// HttpMessageNotReadableException and NoResourceFoundException, and answers
// 500 to all of them.
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Replaces the BODY of every exception the base class handles, while the
// base class keeps deciding the STATUS. This is the hook to override -
// not the individual handleHttpRequestMethodNotSupported methods.
@Override
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {
logger.warn("Request rejected: {} -> {}", ex.getClass().getSimpleName(), statusCode.value());
return new ResponseEntity<>(
new ErrorResponse("Request could not be processed", "BAD_REQUEST"),
headers, statusCode);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
// Log details server-side
logger.warn("Resource not found: {}", ex.getMessage());
// "Not found" is a fact about the request, so it is safe to state
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Resource not found", "NOT_FOUND"));
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDatabaseError(DataAccessException ex) {
// Log full error with stack trace server-side, under a correlation id
String errorId = java.util.UUID.randomUUID().toString();
logger.error("errorId={} database error", errorId, ex);
// The client is told nothing about which subsystem failed
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("An unexpected error occurred (ref " + errorId + ")",
"INTERNAL_ERROR"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
String errorId = java.util.UUID.randomUUID().toString();
logger.error("errorId={} unhandled exception", errorId, ex);
// Return generic error to client (no stack trace!)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("An unexpected error occurred (ref " + errorId + ")",
"INTERNAL_ERROR"));
}
}
Why this works: Exception handling is centralized, so the full detail - stack trace included - goes to the log and only a fixed string goes to the caller. Every 500 carries a random errorId that also appears in the log line, which preserves the operational value a descriptive message used to provide: support can find the exact record without the response saying anything.
Two parts of this are the fix rather than the packaging.
Extend ResponseEntityExceptionHandler rather than writing a bare @ExceptionHandler(Exception.class). Spring signals ordinary client errors as exceptions, and Exception.class is a supertype of all of them, so a catch-all advice takes them over and returns whatever status it was written with. Measured on Spring Boot 3.5.6 against an application whose only advice was the catch-all, every one of these came back 500:
| Request | Spring's exception | Stock status | With a bare catch-all |
|---|---|---|---|
POST to a @GetMapping route |
HttpRequestMethodNotSupportedException |
405 | 500 |
Content-Type: text/plain to a JSON route |
HttpMediaTypeNotSupportedException |
415 | 500 |
| Malformed JSON body | HttpMessageNotReadableException |
400 | 500 |
| Unknown path | NoResourceFoundException |
404 | 500 |
Nothing leaks, so a re-scan passes and a test asserting "the body is generic" passes. What breaks is the caller's ability to tell "you sent the wrong thing" from "we are broken", and the on-call rotation's ability to tell the same. Overriding handleExceptionInternal replaces the body for all of them at once while the base class keeps choosing the status; re-running the table above after the change returned 405, 415, 400 and 404.
Keep the subsystem out of the error code. An earlier version of this example returned "Database operation failed" with the code DB_ERROR. That reads as sanitized and is not: it confirms a database sits behind the endpoint, and it tells an attacker that a payload reaching it produced a server-side failure rather than a validation rejection. The test to apply to any client-visible message or code is whether it describes the caller's situation or your architecture - Resource not found is a fact about the request and is safe to state; anything a reader could use to sketch the stack belongs in the log line with an error ID standing in for it. CWE-209 covers the error-message channel in more depth.
Secure Authentication with Generic Error Messages
// SECURE - Preventing user enumeration with consistent error messages
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.PostConstruct;
import java.util.UUID;
import lombok.Data;
@Data
class LoginRequest {
private String username;
private String password;
}
@Data
@AllArgsConstructor
class LoginResponse {
private String token;
private UserDTO user;
}
@RestController
@RequestMapping("/api")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
private static final String GENERIC_ERROR = "Invalid credentials";
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private TokenService tokenService;
// Real hash of a value no account can hold, generated at startup so the
// dummy verification below runs the encoder's configured work factor
private String dummyHash;
@PostConstruct
void initDummyHash() {
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
// Generic error response for all failure cases
ErrorResponse genericError = new ErrorResponse(GENERIC_ERROR, "AUTH_FAILED");
// Input validation
if (request.getUsername() == null || request.getPassword() == null) {
return ResponseEntity.status(401).body(genericError);
}
User user = userRepository.findByUsername(request.getUsername());
// Log server-side for security monitoring
if (user == null) {
logger.warn("Login attempt for non-existent user: {}", request.getUsername());
// Verify against a real hash so this path costs what a genuine
// check costs - a placeholder string is rejected as malformed
// before any hashing happens, leaving exactly the timing gap
// this call is meant to close
passwordEncoder.matches(request.getPassword(), dummyHash);
return ResponseEntity.status(401).body(genericError);
}
// Check password
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
logger.warn("Failed login attempt for user: {}", request.getUsername());
return ResponseEntity.status(401).body(genericError);
}
// Success - log and return only safe data
logger.info("Successful login for user: {}", request.getUsername());
String token = tokenService.generateToken(user);
UserDTO userDTO = UserDTO.fromEntity(user);
return ResponseEntity.ok(new LoginResponse(token, userDTO));
}
}
Why this works: Identical error messages for all authentication failures prevent user enumeration. The dummy check verifies against a hash the encoder produced itself, so the unknown-username path pays the same bcrypt cost as a real verification; BCryptPasswordEncoder.matches() given a malformed placeholder logs "Encoded password does not look like BCrypt" and returns false without hashing anything, which leaves the timing difference it was supposed to hide. Detailed failures are logged server-side only, while responses contain only safe user fields.
Secure Logging with Sensitive Data Filtering
// SECURE - Log message converter and object sanitization
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.pattern.ThrowableProxyConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.Data;
import lombok.ToString;
import java.util.regex.Pattern;
// Pattern converter that rewrites the message on its way into the log line.
// A Filter cannot do this: decide() returns ACCEPT/DENY/NEUTRAL and has no way
// to alter the event, so a filter that edits a local copy of the message
// changes nothing and the original is written unmodified.
public class SensitiveDataConverter extends MessageConverter {
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("(password[\\\"']?\\s*[:=]\\s*[\\\"']?)([^\\\"'\\s,}]+)",
Pattern.CASE_INSENSITIVE);
private static final Pattern TOKEN_PATTERN =
Pattern.compile("(token[\\\"']?\\s*[:=]\\s*[\\\"']?)([^\\\"'\\s,}]+)",
Pattern.CASE_INSENSITIVE);
private static final Pattern CREDIT_CARD_PATTERN =
Pattern.compile("\\b\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}\\b");
static String redact(String text) {
// Backstop only - regexes catch text assembled elsewhere, they do not
// replace keeping the values out of the log call (see toString() below)
text = PASSWORD_PATTERN.matcher(text).replaceAll("$1[REDACTED]");
text = TOKEN_PATTERN.matcher(text).replaceAll("$1[REDACTED]");
text = CREDIT_CARD_PATTERN.matcher(text).replaceAll("XXXX-XXXX-XXXX-XXXX");
return text;
}
@Override
public String convert(ILoggingEvent event) {
return redact(super.convert(event));
}
}
// The message converter above never sees the exception: PatternLayout renders
// a throwable through a separate converter, so logger.error(msg, ex) writes the
// exception message and stack trace verbatim however the message is filtered.
// Register this alongside it and use %safeex in the pattern.
public class SensitiveThrowableConverter extends ThrowableProxyConverter {
@Override
protected String throwableProxyToString(IThrowableProxy tp) {
return SensitiveDataConverter.redact(super.throwableProxyToString(tp));
}
}
// Request/Response objects with sensitive field exclusion
@Data
class PaymentRequest {
private String userId;
private String cardNumber;
private String cvv;
private BigDecimal amount;
// Override toString to exclude sensitive fields
@Override
public String toString() {
return "PaymentRequest{" +
"userId='" + userId + '\'' +
", cardNumber='[REDACTED]'" +
", cvv='[REDACTED]'" +
", amount=" + amount +
'}';
}
}
// Alternative: Use Lombok's @ToString with exclude
@Data
@ToString(exclude = {"password", "cardNumber", "cvv", "apiKey", "secret"})
class SecureRequest {
private String username;
private String password; // Won't appear in toString()
private String cardNumber; // Won't appear in toString()
private String cvv; // Won't appear in toString()
}
@RestController
public class SecurePaymentController {
private static final Logger logger = LoggerFactory.getLogger(SecurePaymentController.class);
@PostMapping("/api/payment")
public ResponseEntity<?> processPayment(@RequestBody PaymentRequest request) {
// Safe to log - sensitive fields redacted by toString()
logger.info("Processing payment: {}", request);
try {
PaymentResult result = chargeCard(request);
// Log only non-sensitive identifiers
logger.info("Payment successful for user: {}, amount: {}",
request.getUserId(), request.getAmount());
return ResponseEntity.ok(new PaymentResponse(result.getTransactionId()));
} catch (Exception e) {
// Log error without sensitive data
logger.error("Payment failed for user: {}", request.getUserId(), e);
return ResponseEntity.status(500)
.body(new ErrorResponse("Payment processing failed", "PAYMENT_ERROR"));
}
}
}
// logback-spring.xml configuration
/*
<configuration>
<!-- Bind each converter to a conversion word. The attribute is `class`;
`converterClass` still works but logback 1.5.8+ warns that it is
deprecated and replaced by `class`. -->
<conversionRule conversionWord="safemsg"
class="com.myapp.security.SensitiveDataConverter"/>
<conversionRule conversionWord="safeex"
class="com.myapp.security.SensitiveThrowableConverter"/>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/application.log</file>
<encoder>
<!-- %safeex replaces the throwable that PatternLayout would
otherwise append unfiltered -->
<pattern>%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %safemsg%n%safeex</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>
*/
Why this works: Overriding toString() or using @ToString(exclude) is the control that matters: the fields cannot reach an appender even when the whole object is logged, and safe identifiers still appear for audit trails. The converters are a backstop for text assembled by hand elsewhere. Redaction has to happen at the converter or the appender because the event itself is immutable; a Filter only decides whether the event is logged at all.
A message converter does not see the exception, and that is where the second copy of the data usually is. PatternLayout renders a throwable through its own converter and appends it after the message, so %safemsg filters the text passed to logger.error(...) and nothing else. Measured on logback 1.5.20 with %safemsg alone, logger.error("Payment failed for user: {}", id, ex) wrote
2026-08-24 08:33:54 - demo.Main - Payment failed for user: u-1
java.lang.IllegalStateException: charge declined for password=secret123 card 4111111111111111
- the message redacted, the exception verbatim. This matters because an exception from a driver, an HTTP client or a validation library routinely quotes the value that caused it. Registering
SensitiveThrowableConverterunder a second word and adding%safeexto the pattern closed it; the same line then readpassword=[REDACTED] card XXXX-XXXX-XXXX-XXXX.
The general form is worth carrying to any logging stack: a redactor attached to the message is not attached to the throwable. The equivalent gap exists in Python's logging (a Filter rewriting record.getMessage() leaves the exc_info traceback untouched) and in winston (a format matching on field names leaves error.message as an ordinary string).
Spring Boot Actuator Secured Configuration
# SECURE - Properly configured Spring Boot Actuator
spring:
datasource:
url: ${DATABASE_URL} # Use environment variables
username: ${DATABASE_USER} # Never hardcode credentials
password: ${DATABASE_PASSWORD}
security:
user:
# The prefix is spring.security.user. Plain security.user was Spring
# Boot 1.x; on 2.x and later it binds to nothing, the property is
# ignored without an error, and the generated random password from
# startup stays in effect.
name: ${ACTUATOR_USER}
password: ${ACTUATOR_PASSWORD}
# Required by the hasRole("ADMIN") chain below - the default user is
# granted ROLE_USER only, so without this every actuator call gets 403.
roles: ADMIN
jwt:
secret: ${JWT_SECRET} # From environment/vault
management:
endpoints:
web:
exposure:
include: health,info # Only expose safe endpoints
base-path: /actuator
endpoint:
env:
show-values: WHEN_AUTHORIZED # Hide sensitive values
health:
show-details: when-authorized # Only show details to authorized users
// Spring Security 6 / Spring Boot 3: WebSecurityConfigurerAdapter is removed;
// configure security as a SecurityFilterChain bean with the lambda DSL
@Configuration
public class ActuatorSecurityConfig {
@Bean
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(authorize -> authorize
// Require admin role for actuator endpoints
.anyRequest().hasRole("ADMIN"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
// Custom info contributor - only safe information
@Component
public class SafeInfoContributor implements InfoContributor {
@Override
public void contribute(Info.Builder builder) {
// Only include safe, non-sensitive information
builder.withDetail("app", Map.of(
"name", "My Application",
"version", "1.0.0",
"environment", "production"
));
// DO NOT include: secrets, credentials, internal paths
}
}
// Sanitize values shown by the /env and /configprops endpoints.
// SanitizingFunction is the supported extension point - beans of this type are
// picked up automatically. EnvironmentEndpointWebExtension is a Spring class,
// not an interface, and is not meant to be implemented.
@Component
public class SensitiveValueSanitizer implements SanitizingFunction {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"password", "secret", "key", "token", "credential", "api"
);
@Override
public SanitizableData apply(SanitizableData data) {
// getKey().toLowerCase(Locale.ROOT) rather than getLowerCaseKey(),
// which only exists from Spring Boot 3.5
String key = data.getKey().toLowerCase(Locale.ROOT);
boolean sensitive = SENSITIVE_KEYS.stream().anyMatch(key::contains);
return sensitive ? data.withSanitizedValue() : data;
}
}
Why this works: Credentials come from environment variables, so none of them sit in a file that ships with the artifact. exposure.include: health,info leaves /env, /configprops and /heapdump unmapped rather than merely protected, the filter chain puts what remains behind an admin role, and SanitizingFunction masks values by key wherever one is still shown.
Jackson Configuration for Entity Serialization Control
// SECURE - Using Jackson annotations to control JSON serialization
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import jakarta.persistence.*;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Entity
@Table(name = "users")
@JsonIgnoreProperties({"passwordHash", "resetToken", "apiKey"}) // Explicit ignore
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@JsonIgnore // Never serialize this field
private String passwordHash;
@JsonIgnore
private String resetToken;
@JsonIgnore
private String apiKey;
@JsonIgnore // Internal authorization flag - never sent to clients
private Boolean isAdmin;
// Getters and setters...
}
// Global Jackson configuration.
//
// Customize the auto-configured mapper; do NOT declare a @Bean ObjectMapper.
// JacksonAutoConfiguration backs off @ConditionalOnMissingBean, so replacing
// the bean discards every module Spring Boot registers - JavaTimeModule
// among them - and any response carrying a java.time value then fails.
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
// Don't serialize null values
.serializationInclusion(JsonInclude.Include.NON_NULL)
// Reject request bodies carrying fields the DTO does not declare
.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
}
Why this works: @JsonIgnore and @JsonIgnoreProperties keep named fields out of the serialized form, so an entity that must be returned directly does not carry its hash or its key. Both are denylists - they protect the fields somebody remembered - which is why the DTO pattern above remains the primary defence and these are the fallback for code that is not being restructured. FAIL_ON_UNKNOWN_PROPERTIES works the other way and is worth having beside them: it rejects a request body containing fields the DTO does not declare, rather than binding what it recognises and ignoring the rest.
Customizing the builder rather than replacing the ObjectMapper bean is not a style preference. Measured on Spring Boot 3.5.6 with the @Bean ObjectMapper form, a controller returning a LocalDateTime failed with InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", surfacing to the caller as a 500 on the success path. Spring Boot registers that module for you and the replacement bean throws it away. Switching to Jackson2ObjectMapperBuilderCustomizer returned {"at":"2026-08-24T08:39:52.19","ok":true} with the same settings applied.
On automatic redaction by field name
A tempting addition here is a global JsonSerializer<String> that inspects the field name and writes [REDACTED] for anything matching password, secret, token, key or credential. It is worth knowing why that is not recommended here.
The match is a substring test over every string field in the application, and English is not on your side. Measured with that serializer registered, monkeyName, sortKey and publicKeyword all serialized as [REDACTED] - key is a substring of all three. The failure is silent, arrives in production data rather than at startup, and is hard to attribute because the field's own class says nothing about redaction. Tightening the list to exact names removes the false positives and, with them, the reason to have the mechanism: at that point you know each field by name and can annotate it.
The control that does generalise is the one that does not need to guess: a response type that names the fields it exposes. Where an annotation-level fallback is genuinely wanted, @JsonIgnore on the specific field is precise, visible at the declaration, and cannot catch a bystander.
Testing
A re-scan confirms the reported line has changed; it cannot confirm any of the following, because each of them either passes silently while still leaking or breaks legitimate traffic while the scanner stays quiet. Assert on the serialized response and on the log file, not on the code.
- Serialize the entity, not the DTO, and assert the DTO's field set exactly.
assertThat(json).doesNotContain("passwordHash")passes for a response that omits it by accident;assertThat(mapper.readTree(json).fieldNames()).containsExactlyInAnyOrder("id", "username", "email")fails the day a field is added. This is the assertion that catches a widened response, which is the whole weakness. - Every status the application can return still comes back as itself.
POSTto a@GetMappingroute returns 405, not 500;Content-Type: text/plainto a JSON route returns 415; a malformed JSON body returns 400; an unmapped path returns 404. A@ControllerAdvicethat swallows these is invisible to every other test, because the bodies are all generic either way. - Two failing logins are byte-identical. Post an unknown username and a known username with a wrong password; assert the status, the body and the header set match. Assert separately that neither response contains the submitted username - echoing it back is what turns a generic message into an enumeration oracle again.
- A 500 response contains no frame, path or type name. Force an exception and assert the body matches the generic shape exactly, and that it contains none of
"at ",".java:","Exception", or the value ofSystem.getProperty("user.dir"). Assert the log contains the stack trace, so a passing test distinguishes "suppressed" from "lost". - The redactors run on the exception, not only the message. Log a canary through
logger.error("failed", new IllegalStateException("password=CANARY"))and assertCANARYis absent from the log file. Without%safeexin the pattern this fails while a test that logs only a message passes. - A response carrying a
java.timevalue still serializes.GETan endpoint returning aLocalDateTimeand assert 200 with an ISO-8601 string. This is the assertion that catches a Jackson configuration change that replaced the auto-configured mapper. /actuator/envis not reachable at all, and/actuator/healthneeds credentials. Assert the two separately, because they fail differently. Withexposure.include=health,info,/actuator/env,/actuator/beansand/actuator/heapdumpreturn 404 to everyone including an authenticated admin - measured on Spring Boot 3.5.6 - so the assertion is 404, not 401. Asserting 401 there passes only if something has re-exposed the endpoint, which inverts the test./actuator/healthreturns 401 anonymously and 200 with credentials; assert both, since a chain that never engaged also returns 200. Then assert on the authenticated body:show-detailsgates real disclosure, and the stockdiskSpacecomponent reports an absolute filesystem path.- The security properties are the ones Spring actually binds. Assert
spring.security.user.nameresolves to the configured value. Written assecurity.user.nameit binds to nothing, no error is raised, and the application starts with the generated random password printed at boot - an actuator that looks configured and is not.
Architecture-specific considerations:
- Jakarta EE/JAX-RS: Test
ExceptionMapperimplementations, validate@JsonbTransientannotations, check server error pages - Quarkus: Verify Dev UI is disabled in production, test
@RegisterForReflectiondoesn't expose sensitive fields - Micronaut: Test
@Introspectedbean serialization, validate error response formatters - Spring Boot: Check actuator security, test
@ControllerAdvicehandlers, verify@JsonIgnoreeffectiveness
Common Pitfalls
@JsonIgnorealone, but Lombok's@Datastill generates a fulltoString():@JsonIgnoreonly controls Jackson serialization for API responses - logging the entity itself (logger.info("User: {}", user)) still prints every field, including the ones the DTO/annotation was meant to hide.- Field-level annotations on the entity, but a second exposure path bypasses them: Spring Data REST projections, GraphQL resolvers, and the actuator
/env//beansendpoints each serialize independently of your controller's@JsonIgnore/DTO - fixing the REST controller doesn't fix these other paths. management.endpoints.web.exposure.include=*orshow-values=ALWAYSleft in a shared or dev configuration file: a profile-specific override (application-dev.yml) that never gets excluded from the production artifact ships wide-open actuator endpoints regardless of what production's own config says.@JsonProperty(access = READ_ONLY)mistaken for a way to hide a field: the name means read-for-serialization - the field is still written to every response, it is only ignored on the way in. Hiding a field from clients is@JsonIgnore, or leaving it off the DTO.- A custom exception's message wraps the original cause's message:
throw new BusinessException("Failed: " + sqlException.getMessage())still carries the SQL/connection detail into whatever the global exception handler ultimately logs or returns, even though the handler itself only returns a generic error string.