Stop Hardcoding DTOs: Dynamic Column‑Level Masking in Spring Boot
This article shows how to replace static DTO definitions with a zero‑intrusion, high‑concurrency solution for column‑level data masking in Spring Boot 3.5.0 by leveraging Jackson's serialization pipeline, a global Mixin, custom annotations, a contextual serializer, and a ResponseBodyAdvice that respects request‑scoped permissions.
In enterprise environments where data compliance and privacy are increasingly strict, a single API must return different data views based on the caller's permissions. Existing masking solutions are static and force developers to write repetitive if‑else blocks or duplicate DTO classes, harming code elegance and maintainability.
The article proposes a fully dynamic, zero‑intrusion approach that uses Jackson's low‑level serialization pipeline. By introducing a global Mixin and a two‑stage property‑filter mechanism, the solution achieves high‑concurrency column‑level masking and complete removal of sensitive keys without third‑party libraries.
1. Define the masking annotation
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
public @interface DynamicMaskFilter {
// Masking strategy; if the caller lacks plain‑text permission, the strategy is applied
MaskStrategy strategy() default MaskStrategy.NONE;
// Permission identifier that allows viewing the plain value
String hasPermission() default "";
}2. Masking strategies
public enum MaskStrategy {
NONE, // No masking; if no permission, the field is removed instead of being null
PHONE, // e.g., 181****1234
ID_CARD // e.g., 110101********1234
}3. Dynamic masking serializer
The serializer implements JsonSerializer<Object> and ContextualSerializer. During the first serialization of a DTO field, it captures the annotation parameters and creates a customized singleton proxy.
public class DynamicMaskSerializer extends JsonSerializer<Object> implements ContextualSerializer {
private MaskStrategy strategy;
private String hasPermission;
public DynamicMaskSerializer() {}
public DynamicMaskSerializer(MaskStrategy strategy, String hasPermission) {
this.strategy = strategy;
this.hasPermission = hasPermission;
}
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
DynamicMaskFilter annotation = property.getAnnotation(DynamicMaskFilter.class);
if (annotation != null) {
return new DynamicMaskSerializer(annotation.strategy(), annotation.hasPermission());
}
}
try {
return prov.findValueSerializer(property.getType(), property);
} catch (JsonMappingException e) {
throw new RuntimeException(e);
}
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) { gen.writeNull(); return; }
Set<String> permissions = SecurityContextHolder.get();
// If permission granted, write the original value
if (hasPermission.isEmpty() || (permissions != null && permissions.contains(hasPermission))) {
gen.writeObject(value);
return;
}
// No permission – apply the selected masking strategy
String rawStr = value.toString();
switch (strategy) {
case PHONE -> gen.writeString(rawStr.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
case ID_CARD -> gen.writeString(rawStr.replaceAll("(\\d{6})\\d+(\\d{4})", "$1********$2"));
default -> gen.writeNull();
}
}
}4. Global Jackson configuration & Mixin
@Configuration
public class JacksonConfig {
public static final String DYNAMIC_FILTER_ID = "DynamicMaskingFilter";
@JsonFilter(DYNAMIC_FILTER_ID)
public interface JacksonFilterMixin {}
@Bean
Jackson2ObjectMapperBuilderCustomizer maskingFilter() {
return builder -> {
builder.mixIn(Object.class, JacksonFilterMixin.class);
SimpleFilterProvider filterProvider = new SimpleFilterProvider()
.addFilter(DYNAMIC_FILTER_ID, SimpleBeanPropertyFilter.serializeAll());
builder.filters(filterProvider);
};
}
}Mixing the filter into Object.class activates the property‑filter pipeline for every response DTO.
5. ResponseBodyAdvice for dynamic injection
@RestControllerAdvice
public class DynamicSecurityResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body == null) return null;
MappingJacksonValue container = new MappingJacksonValue(body);
PropertyFilter dynamicFilter = new SimpleBeanPropertyFilter() {
@Override
public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) throws Exception {
DynamicMaskFilter annotation = writer.getAnnotation(DynamicMaskFilter.class);
if (annotation != null) {
Set<String> permissions = SecurityContextHolder.get();
if (annotation.strategy() == MaskStrategy.NONE && !annotation.hasPermission().isEmpty()
&& (permissions == null || !permissions.contains(annotation.hasPermission()))) {
return; // skip field
}
}
writer.serializeAsField(pojo, jgen, provider);
}
};
SimpleFilterProvider filterProvider = new SimpleFilterProvider()
.addFilter(JacksonConfig.DYNAMIC_FILTER_ID, dynamicFilter);
container.setFilters(filterProvider);
return container;
}
}6. Simulated permission handling
An interceptor extracts a role request parameter and populates a thread‑local permission set.
@Component
public class SecurityContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Set<String> permissions = new HashSet<>();
String role = request.getParameter("role");
if ("ADMIN".equalsIgnoreCase(role)) {
permissions.add("user:view:phone");
permissions.add("user:view:salary");
} else if ("HR".equalsIgnoreCase(role)) {
permissions.add("user:view:phone");
}
SecurityContextHolder.set(permissions);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
SecurityContextHolder.clear(); // prevent memory leak
}
}The interceptor is registered globally:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final SecurityContextInterceptor interceptor;
public WebMvcConfig(SecurityContextInterceptor interceptor) { this.interceptor = interceptor; }
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor).addPathPatterns("/**");
}
}7. Permission holder
public class SecurityContextHolder {
private static final ThreadLocal<Set<String>> CONTEXT = new ThreadLocal<>();
public static void set(Set<String> permissions) { CONTEXT.set(permissions); }
public static Set<String> get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}8. Example DTO and controller
public class UserDto {
private Long id;
private String name;
private Integer age;
@DynamicMaskFilter(strategy = MaskStrategy.PHONE, hasPermission = "user:view:phone")
@JsonSerialize(using = DynamicMaskSerializer.class)
private String phone;
@DynamicMaskFilter(strategy = MaskStrategy.NONE, hasPermission = "user:view:salary")
private BigDecimal salary;
}
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
return new UserDto(id, "李四", 33, "18188889999", BigDecimal.valueOf(50000));
}
}When a request includes ?role=ADMIN, both phone and salary are returned in plain text. With ?role=HR, only phone is visible; salary is omitted. Without a role, the fields are masked or removed according to the strategy.
Result screenshots (omitted here) demonstrate the dynamic masking behavior.
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.
