Skip to content

CWE-201: Insertion of Sensitive Information Into Sent Data - Java

Overview

In Java applications, CWE-201 vulnerabilities commonly occur when developers inadvertently expose sensitive information in HTTP responses, error messages, exception stack traces, or API responses. Java's strong typing and enterprise frameworks like Spring Boot, Jakarta EE, and Hibernate provide security features, but misconfigurations and improper exception handling can still leak passwords, tokens, internal paths, PII, database connection strings, and system details.

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. Spring Boot's default error pages and Jackson's default serialization behavior can expose internal system details if not properly configured.

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. While frameworks provide @JsonIgnore and security configurations, developers must explicitly use them and implement proper data transfer patterns.

Primary Defence: Use Data Transfer Objects (DTOs) or @JsonView to control field exposure instead of serializing entities directly, implement global @ControllerAdvice exception handlers that return generic error messages while logging full details server-side, configure Logback filters to redact sensitive fields (passwords, tokens, credit cards), and secure Spring Boot Actuator endpoints with authentication to prevent information disclosure through APIs, logs, and error responses.

This guidance demonstrates how to prevent information disclosure in Java applications using DTOs, proper exception handling, secure logging practices, and configuration hardening across Spring Boot, Jakarta EE, and Hibernate.

Common Vulnerable Patterns

Direct Entity Serialization in REST Controllers

// VULNERABLE - Exposing entire JPA entity including sensitive fields
import org.springframework.web.bind.annotation.*;
import javax.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!
// }

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!

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!

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!

Configuration Exposure Through Actuator

// VULNERABLE - Exposing configuration and secrets via Spring Boot Actuator
// application.properties
spring.datasource.url=jdbc:postgresql://db.internal.com:5432/production
spring.datasource.username=admin
spring.datasource.password=SuperSecret123!  // DANGEROUS!

management.endpoints.web.exposure.include=*  // EXPOSES ALL ENDPOINTS!
management.endpoint.env.show-values=ALWAYS   // SHOWS ALL VALUES!

// 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!
//   ...
// }

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 javax.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: DTOs create an explicit allowlist of safe fields, preventing accidental exposure when new entity fields are added. Type-safe conversion from entity to DTO ensures clear separation between the domain model and API responses, eliminating the risk of serializing sensitive database columns.

Global Exception Handling with Generic Error Responses

// SECURE - Centralized exception handling with generic user errors
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.Data;
import lombok.AllArgsConstructor;

// Standardized error response
@Data
@AllArgsConstructor
public class ErrorResponse {
    private String error;
    private String errorCode;
    private Long timestamp;

    public ErrorResponse(String error, String errorCode) {
        this.error = error;
        this.errorCode = errorCode;
        this.timestamp = System.currentTimeMillis();
    }
}

// Global exception handler
@ControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        // Log details server-side
        logger.warn("Resource not found: {}", ex.getMessage());

        // Return generic error to client
        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
        logger.error("Database error occurred", ex);

        // Return generic error to client (no SQL details!)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("Database operation failed", "DB_ERROR"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        // Log full error with stack trace server-side
        logger.error("Unhandled exception occurred", ex);

        // Return generic error to client (no stack trace!)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("An unexpected error occurred", "INTERNAL_ERROR"));
    }
}

// Application configuration
@Configuration
public class WebConfig {

    @Bean
    public ErrorAttributes errorAttributes() {
        return new DefaultErrorAttributes() {
            @Override
            public Map<String, Object> getErrorAttributes(WebRequest request, 
                    ErrorAttributeOptions options) {
                // Override default Spring Boot error page
                Map<String, Object> errorAttributes = new HashMap<>();
                errorAttributes.put("error", "An error occurred");
                errorAttributes.put("timestamp", new Date());
                // Don't include: exception, message, trace, path details
                return errorAttributes;
            }
        };
    }
}

Why this works: Centralized exception handling logs full error details (including stack traces) server-side for debugging while returning only generic error messages to clients. Error codes enable support tracking without exposing technical details like SQL errors, file paths, or internal system structure.

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 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;

    @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());

            // Perform dummy password check to prevent timing attacks
            passwordEncoder.matches(request.getPassword(), 
                "$2a$10$dummyhashfortimingatk");

            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 password check maintains constant execution time, preventing timing attacks that could reveal which usernames exist. Detailed failures are logged server-side only, while responses contain only safe user fields.

Secure Logging with Sensitive Data Filtering

// SECURE - Custom log filter and object sanitization
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.filter.Filter;
import ch.qos.logback.core.spi.FilterReply;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.Data;
import lombok.ToString;
import java.util.regex.Pattern;
import java.util.regex.Matcher;

// Custom Logback filter to redact sensitive patterns
public class SensitiveDataFilter extends Filter<ILoggingEvent> {

    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");

    @Override
    public FilterReply decide(ILoggingEvent event) {
        String message = event.getFormattedMessage();

        // Redact sensitive patterns
        message = PASSWORD_PATTERN.matcher(message).replaceAll("$1[REDACTED]");
        message = TOKEN_PATTERN.matcher(message).replaceAll("$1[REDACTED]");
        message = CREDIT_CARD_PATTERN.matcher(message).replaceAll("XXXX-XXXX-XXXX-XXXX");

        // Note: This is a simplified example. In production, use Logback's 
        // MessageConverter or custom Appender for proper filtering

        return FilterReply.NEUTRAL;
    }
}

// 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>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>logs/application.log</file>
        <filter class="com.myapp.security.SensitiveDataFilter"/>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="FILE"/>
    </root>
</configuration>
*/

Why this works: Custom Logback filters automatically redact sensitive patterns (passwords, tokens, credit cards) before writing to logs. Overriding toString() or using @ToString(exclude) prevents sensitive fields from appearing in log statements, while still allowing logging of safe identifiers for audit trails.

Spring Boot Actuator Secured Configuration

application.yml
// SECURE - Properly configured Spring Boot Actuator
spring:
  datasource:
    url: ${DATABASE_URL}          # Use environment variables
    username: ${DATABASE_USER}    # Never hardcode credentials
    password: ${DATABASE_PASSWORD}

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

security:
  user:
    name: ${ACTUATOR_USER}        # Require authentication
    password: ${ACTUATOR_PASSWORD}
@Configuration
public class ActuatorSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .requestMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeRequests()
                // Require admin role for actuator endpoints
                .anyRequest().hasRole("ADMIN")
            .and()
            .httpBasic();
    }
}

// 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 environment endpoint
@Component
public class EnvironmentEndpointSanitizer implements EnvironmentEndpointWebExtension {

    private static final Set<String> SENSITIVE_KEYS = Set.of(
        "password", "secret", "key", "token", "credential", "api"
    );

    // Override to sanitize sensitive values
    public Map<String, Object> sanitizeEnvironment(Map<String, Object> env) {
        return env.entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                entry -> isSensitive(entry.getKey()) ? "[REDACTED]" : entry.getValue()
            ));
    }

    private boolean isSensitive(String key) {
        String lowerKey = key.toLowerCase();
        return SENSITIVE_KEYS.stream().anyMatch(lowerKey::contains);
    }
}

Why this works: Loading credentials from environment variables prevents hardcoded secrets. Restricting actuator endpoint exposure to only safe endpoints (health, info), requiring authentication, and sanitizing environment values prevents attackers from discovering database credentials, API keys, and internal system configuration.

Jackson Configuration for Entity Serialization Control

// SECURE - Using Jackson annotations to control JSON serialization
import com.fasterxml.jackson.annotation.*;
import javax.persistence.*;

@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;

    @JsonProperty(access = JsonProperty.Access.READ_ONLY)  // Only for serialization
    private Boolean isAdmin;

    // Getters and setters...
}

// Global Jackson configuration
@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();

        // Don't serialize null values
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        // Fail on unknown properties during deserialization
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);

        // Add custom serializer for sensitive fields
        SimpleModule module = new SimpleModule();
        module.addSerializer(String.class, new SensitiveStringSerializer());
        mapper.registerModule(module);

        return mapper;

    }
}

// Custom serializer for auto-redacting sensitive field names
public class SensitiveStringSerializer extends JsonSerializer<String> {

    private static final Set<String> SENSITIVE_FIELD_NAMES = Set.of(
        "password", "secret", "token", "key", "credential"
    );

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider provider) 
            throws IOException {
        String fieldName = gen.getOutputContext().getCurrentName();

        if (fieldName != null && isSensitiveFieldName(fieldName)) {
            gen.writeString("[REDACTED]");
        } else {
            gen.writeString(value);
        }
    }

    private boolean isSensitiveFieldName(String fieldName) {
        String lower = fieldName.toLowerCase();
        return SENSITIVE_FIELD_NAMES.stream().anyMatch(lower::contains);
    }
}

Why this works: @JsonIgnore and @JsonIgnoreProperties annotations prevent specific fields from being serialized into JSON responses. Global Jackson configuration with custom serializers provides automatic redaction of sensitive field names, creating a defense-in-depth approach that works even if individual annotations are forgotten.

Testing Strategies

Effective testing for information disclosure requires understanding what sensitive data exists in your application and where it might leak. These test patterns demonstrate key verification approaches, but you'll need to adapt them to your specific architecture, domain model, and risk profile.

Key testing principles:

  1. Test actual HTTP responses - Serialize real domain objects and inspect the JSON/XML output
  2. Verify error scenarios - Trigger exceptions and confirm stack traces aren't exposed
  3. Check consistency across failure modes - Authentication errors should be identical regardless of why they failed
  4. Inspect logs programmatically - Capture log output during tests to verify sensitive data isn't logged
  5. Test boundaries between layers - Verify DTOs/view models truly exclude sensitive entity fields
  6. Validate configuration - Ensure actuator endpoints, error pages, and debug modes are secure by default

Architecture-specific considerations:

  • Jakarta EE/JAX-RS: Test ExceptionMapper implementations, validate @JsonbTransient annotations, check server error pages
  • Quarkus: Verify Dev UI is disabled in production, test @RegisterForReflection doesn't expose sensitive fields
  • Micronaut: Test @Introspected bean serialization, validate error response formatters
  • Spring Boot: Check actuator security, test @ControllerAdvice handlers, verify @JsonIgnore effectiveness

Additional Resources