Backend Development 8 min read

Configurable Data Desensitization in Spring Boot Using Custom Annotations and Serializer

This article demonstrates how to create a configurable data‑masking solution for Spring Boot APIs by defining a custom @DataMasking annotation, implementing masking strategies, building a custom Jackson serializer, integrating it via an AnnotationIntrospector, and testing the functionality with a sample controller.

Architecture Digest
Architecture Digest
Architecture Digest
Configurable Data Desensitization in Spring Boot Using Custom Annotations and Serializer

The need for data desensitization arises when API responses contain sensitive information that must be masked before being sent to clients.

To avoid repetitive masking logic across multiple endpoints, a configurable, annotation‑driven approach is proposed. A @DataMasking annotation is defined, allowing developers to specify a masking strategy from the DataMaskingFunc enum.

Instead of using @ControllerAdvice with reflection, a custom Jackson DataMaskingSerializer is created, extending StdScalarSerializer . It applies the selected masking operation to String fields during serialization.

An DataMaskingAnnotationIntroSpector extends NopAnnotationIntrospector to detect the custom annotation and return the appropriate serializer.

The default ObjectMapper is overridden in DataMaskConfiguration to combine the existing introspector with the custom one, ensuring the annotation is respected globally.

A sample entity User shows how to annotate fields (e.g., @DataMasking(maskFunc = DataMaskingFunc.ALL_MASK) ) that require masking.

package com.wkf.workrecord.tools.desensitization;

import java.lang.annotation.*;
/**
 * Annotation class
 */
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataMasking {
    DataMaskingFunc maskFunc() default DataMaskingFunc.NO_MASK;
}
public interface DataMaskingOperation {
    String MASK_CHAR = "*";
    String mask(String content, String maskChar);
}
public enum DataMaskingFunc {
    NO_MASK((str, maskChar) -> str),
    ALL_MASK((str, maskChar) -> {
        if (StringUtils.hasLength(str)) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < str.length(); i++) {
                sb.append(StringUtils.hasLength(maskChar) ? maskChar : DataMaskingOperation.MASK_CHAR);
            }
            return sb.toString();
        } else {
            return str;
        }
    });
    private final DataMaskingOperation operation;
    DataMaskingFunc(DataMaskingOperation operation) { this.operation = operation; }
    public DataMaskingOperation operation() { return this.operation; }
}
public final class DataMaskingSerializer extends StdScalarSerializer
{
    private final DataMaskingOperation operation;
    public DataMaskingSerializer() { super(String.class, false); this.operation = null; }
    public DataMaskingSerializer(DataMaskingOperation operation) { super(String.class, false); this.operation = operation; }
    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        String content = (operation == null) ?
            DataMaskingFunc.ALL_MASK.operation().mask((String) value, null) :
            operation.mask((String) value, null);
        gen.writeString(content);
    }
    // other overridden methods omitted for brevity
}
public class DataMaskingAnnotationIntroSpector extends NopAnnotationIntrospector {
    @Override
    public Object findSerializer(Annotated am) {
        DataMasking annotation = am.getAnnotation(DataMasking.class);
        if (annotation != null) {
            return new DataMaskingSerializer(annotation.maskFunc().operation());
        }
        return null;
    }
}
@Configuration(proxyBeanMethods = false)
public class DataMaskConfiguration {
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
    static class JacksonObjectMapperConfiguration {
        @Bean @Primary
        ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
            ObjectMapper objectMapper = builder.createXmlMapper(false).build();
            AnnotationIntrospector ai = objectMapper.getSerializationConfig().getAnnotationIntrospector();
            AnnotationIntrospector newAi = AnnotationIntrospectorPair.pair(ai, new DataMaskingAnnotationIntroSpector());
            objectMapper.setAnnotationIntrospector(newAi);
            return objectMapper;
        }
    }
}
@Data
public class User implements Serializable {
    private Long id;
    @DataMasking(maskFunc = DataMaskingFunc.ALL_MASK)
    private String name;
    private Integer age;
    @DataMasking(maskFunc = DataMaskingFunc.ALL_MASK)
    private String email;
}

A test controller DesensitizationController creates a User instance, populates fields, and returns it via a standard response wrapper. When accessed through Postman, the annotated fields are masked according to the configured strategy, confirming the solution works as intended.

backendJavaSpring BootCustom Annotationdata maskingSerializer
Architecture Digest
Written by

Architecture Digest

Focusing on Java backend development, covering application architecture from top-tier internet companies (high availability, high performance, high stability), big data, machine learning, Java architecture, and other popular fields.

0 followers
Reader feedback

How this landed with the community

login 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.