Configurable Data Masking in Spring Boot Using Custom Annotations and Jackson
This article demonstrates how to implement a flexible data‑masking solution for sensitive fields in Spring Boot APIs by defining a custom annotation, creating a serializer, integrating it with Jackson via an AnnotationIntrospector, configuring the ObjectMapper, and applying the annotation to domain objects.
During a product discussion the author was asked to mask sensitive data returned by certain API endpoints. To avoid writing repetitive masking logic for each interface, a configurable, annotation‑driven solution is proposed.
Approach
The solution consists of five steps: define a data‑masking annotation, implement a custom serializer, create an AnnotationIntrospector that links the annotation to the serializer, override the default ObjectMapper to register the introspector, and finally annotate the fields that need masking.
1. Custom DataMasking Annotation
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataMasking {
DataMaskingFunc maskFunc() default DataMaskingFunc.NO_MASK;
}2. Custom Serializer (based on Jackson's StdScalarSerializer )
public final class DataMaskingSerializer extends StdScalarSerializer<Object> {
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 boolean isEmpty(SerializerProvider prov, Object value) {
String str = (String) value;
return str.isEmpty();
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
if (Objects.isNull(operation)) {
String content = DataMaskingFunc.ALL_MASK.operation().mask((String) value, null);
gen.writeString(content);
} else {
String content = operation.mask((String) value, null);
gen.writeString(content);
}
}
@Override
public void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, TypeSerializer typeSer) throws IOException {
this.serialize(value, gen, provider);
}
@Override
public JsonNode getSchema(SerializerProvider provider, Type typeHint) {
return this.createSchemaNode("string", true);
}
@Override
public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException {
this.visitStringFormat(visitor, typeHint);
}
}3. DataMaskingOperation Interface and Strategies
public interface DataMaskingOperation {
String MASK_CHAR = "*";
String mask(String content, String maskChar);
}
public enum DataMaskingFunc {
/** No masking */
NO_MASK((str, maskChar) -> str),
/** Mask every character */
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();
}
return str;
});
private final DataMaskingOperation operation;
DataMaskingFunc(DataMaskingOperation operation) { this.operation = operation; }
public DataMaskingOperation operation() { return this.operation; }
}4. Custom AnnotationIntrospector
@Slf4j
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;
}
}5. Overriding the ObjectMapper Configuration
@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;
}
}6. Applying the Annotation to Domain Objects
public class User implements Serializable {
/** Primary key */
private Long id;
/** Name – fully masked */
@DataMasking(maskFunc = DataMaskingFunc.ALL_MASK)
private String name;
/** Age – no masking */
private Integer age;
/** Email – fully masked */
@DataMasking(maskFunc = DataMaskingFunc.ALL_MASK)
private String email;
}With this setup, any field annotated with @DataMasking will be automatically processed by Jackson during serialization, applying the selected masking strategy without additional code in controllers or services.
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.
Top Architect
Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.
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.
