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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Stop Hardcoding DTOs: Dynamic Column‑Level Masking in Spring Boot

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

DTOSpring BootJacksonDynamic MaskingSecurityContextResponseBodyAdvice
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.