Zero-Code Changes: Dynamic Field Masking in Spring Boot
This guide shows how to mask sensitive fields like phone numbers and ID cards in Spring Boot responses without modifying business code, using a global ResponseBodyAdvice, JsonPath rules defined in application.yml, and optional custom @Masking annotation for fine-grained control.
Environment: Spring Boot 3.5.0
1. Introduction
In web applications, sensitive data such as phone numbers, ID numbers, and bank cards must be masked when transmitted between front‑end and back‑end (e.g., 138****5678). Traditional solutions either hard‑code masking logic in business code or create custom DTOs for each endpoint, which is time‑consuming and error‑prone, especially for deeply nested fields.
This article demonstrates a zero‑code‑change solution that intercepts the response at a global “exit” point. Developers only need to write a JsonPath expression in the configuration file; the system automatically masks the matched fields.
2. Practical Example
2.1 Property Definition
@Component
@ConfigurationProperties(prefix = "pack.masking")
public class MaskingProperties {
private Boolean enabled;
private Map<String, List<String>> masks;
// getters, setters
}Configuration (application.yml)
pack:
masking:
enabled: true
masks:
'[queryUser]': $..phone,$..idNo
'[com.pack.masking.test.UserController.getUserList]': $..cardNo,$..emailThe key can be the controller class name + "." + method name, or a custom name defined by the annotation.
2.2 Custom Annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Masking {
/** Name that corresponds to the entry in the configuration file: pack.masking.xxx */
String value() default "";
/** Fields to mask */
String[] mask() default {};
}2.3 ResponseBodyAdvice Implementation
@ControllerAdvice
@ConditionalOnProperty(prefix = "pack.masking", name = "enabled", havingValue = "true", matchIfMissing = true)
public class MaskingResponseBodyAdvice implements ResponseBodyAdvice<Object> {
private final MaskingProperties maskingProperties;
private final ObjectMapper objectMapper;
public MaskingResponseBodyAdvice(MaskingProperties maskingProperties, ObjectMapper objectMapper) {
this.maskingProperties = maskingProperties;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return false;
}
List<String> masks = null;
Masking annotation = returnType.getMethodAnnotation(Masking.class);
if (annotation != null) {
if (annotation.mask().length > 0) {
masks = Arrays.asList(annotation.mask());
} else {
String value = annotation.value();
if (StringUtils.hasLength(value)) {
masks = this.maskingProperties.getMasks().get(value);
}
}
}
if (masks == null || masks.isEmpty()) {
String methodName = returnType.getMethod().getName();
masks = this.maskingProperties.getMasks()
.get(returnType.getMethod().getDeclaringClass().getName() + "." + methodName);
}
if (masks != null && !masks.isEmpty()) {
attributes.setAttribute("CURRENT_MASKS", masks, RequestAttributes.SCOPE_REQUEST);
return true;
}
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
ServerHttpResponse response) {
if (body == null) {
return null;
}
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return body;
}
@SuppressWarnings("unchecked")
List<String> paths = (List<String>) attributes.getAttribute("CURRENT_MASKS", RequestAttributes.SCOPE_REQUEST);
if (paths == null || paths.isEmpty()) {
return body;
}
try {
String originalJson = objectMapper.writeValueAsString(body);
originalJson = JsonModifier.modify(originalJson, paths);
java.lang.reflect.Type genericType = returnType.getGenericParameterType();
com.fasterxml.jackson.databind.JavaType jacksonType = objectMapper.getTypeFactory().constructType(genericType);
return objectMapper.readValue(originalJson, jacksonType);
} catch (Exception e) {
return body;
}
}
}2.4 Utility Classes
// JSON field modification utility
public class JsonModifier {
private static final Configuration configuration = Configuration.builder()
.jsonProvider(new JacksonJsonProvider())
.mappingProvider(new JacksonMappingProvider())
.options(Option.SUPPRESS_EXCEPTIONS)
.build();
public static String modify(String originalJson, List<String> paths) {
if (originalJson == null || originalJson.isBlank()) {
return originalJson;
}
DocumentContext context = JsonPath.using(configuration).parse(originalJson);
for (String path : paths) {
try {
context.map(path, (currentValue, config) -> {
if (currentValue == null) {
return null;
}
return StringMaskUtils.mask((String) currentValue);
});
} catch (Exception e) {
e.printStackTrace();
}
}
return context.jsonString();
}
}
// Simple string masking utility
public final class StringMaskUtils {
private StringMaskUtils() {}
public static String mask(String source) {
if (!StringUtils.hasLength(source)) {
return source;
}
String trimmed = source.trim();
int length = trimmed.length();
if (length < 2) {
return source;
}
return switch (length) {
case 2 -> trimmed.charAt(0) + "*";
case 3 -> trimmed.charAt(0) + "*" + trimmed.charAt(2);
case 4 -> trimmed.substring(0, 2) + "**";
default -> maskLongString(trimmed, length);
};
}
private static String maskLongString(String source, int length) {
int keep = Math.max(2, Math.min(length / 4, 4));
int maskLength = length - (keep * 2);
return new StringBuilder()
.append(source, 0, keep)
.append("*".repeat(maskLength))
.append(source, length - keep, length)
.toString();
}
}2.5 Test Controller
public record User(String name, Integer age, String phone, String idNo, String cardNo, String email) {}
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
@Masking(mask = { "$..phone", "$..idNo" })
public User getSingleUser() {
User data = new User("Zhuge Kongming", 28, "13812345678", "11010119980101234X",
"6222021234567890", "[email protected]");
return data;
}
@GetMapping("/list")
public List<User> getUserList() {
List<User> datas = List.of(
new User("Sima Yi", 35, "13900001111", "110101199105054321", "6225888844442222", "[email protected]"),
new User("Zhou Yu", 24, "13588889999", "310101200210108888", "6217999900001111", "[email protected]"),
new User("Cao Cao", 45, "18866667777", "32010119811212999X", "6226111133335555", "[email protected]"));
return datas;
}
}Running the application returns JSON where the phone, ID number, card number and email fields are replaced with masked strings (e.g., "138****5678"). The screenshots below show the original and masked outputs.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
