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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Zero-Code Changes: Dynamic Field Masking in Spring Boot

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,$..email

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

Masked output
Masked output
Another view
Another view
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.

spring-bootcustom annotationJacksonJsonPathDynamic MaskingResponseBodyAdvice
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.