How to Implement Dynamic, Configurable Data Desensitization in Spring Boot

This article walks through a Spring Boot solution for dynamically masking sensitive user data—such as names, ID numbers, phone numbers, and custom fields—by defining custom annotations, a configurable rule engine, a Redis‑backed context holder, and a Jackson serializer that applies masking at JSON serialization time, all while keeping performance overhead low.

Shepherd Advanced Notes
Shepherd Advanced Notes
Shepherd Advanced Notes
How to Implement Dynamic, Configurable Data Desensitization in Spring Boot

1. Background

In modern internet applications, protecting user privacy is critical. Sensitive fields (e.g., name, mobile number, ID card, address) are stored encrypted in the database, but must be masked before being sent to the front‑end. The requirement is that any field can be masked on demand: "mask wherever you want, mask whoever you want".

2. Implementation Idea

Masking directly in each controller is inefficient, so the solution uses an AOP‑style approach. Fields that need masking are annotated, and a global aspect (implemented via a custom JsonSerializer) handles the masking logic. The design avoids per‑request reflection by leveraging @JsonFormat -like custom serializers.

Key steps:

Define a @MaskField annotation that carries a MaskEnum value indicating the masking type.

Create MaskEnum with entries for built‑in fields (NAME, ID_CARD, MOBILE, ADDRESS, EMAIL, BANK_CARD) and a CUSTOM_FIELD for user‑defined JSON fields.

Implement a filter ( AuthFilter) that runs after authentication, reads the company‑specific masking configuration from Redis, and stores it in a thread‑local MaskContextHolder. MaskContextHolder holds the current request’s MaskSetting (global switch, role switches, department IDs, and a list of DesensitizationRuleCreate rules) and a Boolean flag indicating whether masking is active.

Define DesensitizationRuleCreate to describe each rule: field name, type (hide/show), scope (head, middle, tail, all, range), count, start, end.

Implement MaskSerializer (extends JsonSerializer and implements ContextualSerializer) that:

Checks the thread‑local flag; if masking is disabled, writes the original value.

Looks up the rule list from the context holder.

Based on the enum value, selects the appropriate rule and calls MaskUtils.commonMask to produce the masked string.

Handles custom fields (a Map<String, String>) by iterating over entries, matching rule names (including address aliases), and applying the same masking logic.

Override createContextual so the serializer is created only once per field, avoiding repeated reflection and keeping performance low.

Masking rules are applied at JSON serialization time, which isolates the business logic from masking concerns.

Annotation Definition

@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskSerializer.class)
public @interface MaskField {
    MaskEnum value();
}

MaskEnum

public enum MaskEnum {
    NAME,
    ID_CARD,
    MOBILE,
    ADDRESS,
    EMAIL,
    BANK_CARD,
    CUSTOM_FIELD
}

Filter and Context Holder

@Component
@Slf4j
public class AuthFilter implements Filter {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
        // after login verification
        String rule = stringRedisTemplate.opsForValue().get(KeyCache.ORG_DESENSITIZATION_SETTING + company.getId());
        if (StringUtils.isNotBlank(rule)) {
            MaskSetting maskSetting = JSON.parseObject(rule, MaskSetting.class);
            Boolean isMask = false;
            Boolean enable = maskSetting.getEnable() == null ? false : maskSetting.getEnable();
            if (enable) {
                // organization filter
                List<Long> depTeamIds = maskSetting.getDepTeamIds();
                Boolean userLevelSwitch = maskSetting.getUserLevelSwitch();
                Boolean adminLevelSwitch = maskSetting.getAdminLevelSwitch();
                // role check (example for sales or visitor)
                if (Objects.nonNull(roleId) && (UserUtils.isChairMan() || UserUtils.isVisitor())) {
                    if (userLevelSwitch && !CollectionUtils.isEmpty(depTeamIds) &&
                        (depTeamIds.contains(userSession.getDepId()) || depTeamIds.contains(userSession.getTeamId()))) {
                        isMask = true;
                    }
                } else if (Objects.nonNull(roleId) && adminLevelSwitch) {
                    isMask = true;
                }
            }
            MaskContextHolder.setMask(isMask);
            if (isMask) {
                MaskContextHolder.setMaskSetting(maskSetting);
            }
        }
        filterChain.doFilter(servletRequest, servletResponse);
    } finally {
        MaskContextHolder.clear();
    }
}

The filter stores the configuration in MaskContextHolder, which is later read by the serializer.

MaskContextHolder

public class MaskContextHolder {
    private static final ThreadLocal<MaskSetting> maskSettingHolder = new ThreadLocal<>();
    private static final ThreadLocal<Boolean> mask = new ThreadLocal<>();

    public static void setMaskSetting(MaskSetting ms) { maskSettingHolder.set(ms); }
    public static MaskSetting getMaskSetting() { return maskSettingHolder.get(); }
    public static void setMask(Boolean isMask) { mask.set(isMask); }
    public static Boolean getMask() { return mask.get() == null ? Boolean.FALSE : mask.get(); }
    public static void clear() { maskSettingHolder.remove(); mask.remove(); }
}

DesensitizationRuleCreate

public class DesensitizationRuleCreate {
    @ApiModelProperty(value = "字段英文名称")
    @NotEmpty
    private String name;
    @ApiModelProperty(value = "0:隐藏,1:显示")
    @NotNull
    private Integer type;
    @ApiModelProperty(value = "规则:开头:0 中间:1 末尾:-1 全部:2 区间:3")
    @NotNull
    private Integer scope;
    @ApiModelProperty(value = "位数")
    private Integer count;
    @ApiModelProperty(value = "开始位数")
    private Integer start;
    @ApiModelProperty(value = "结束位数")
    private Integer end;
}

MaskSerializer

public class MaskSerializer<T> extends JsonSerializer<T> implements ContextualSerializer {
    private MaskEnum type;
    private static final List<String> ADDRESS = Lists.newArrayList(
        "address", "house_address", "company_address", "native_address", "bill_path", "other_address");

    @Override
    public void serialize(T t, JsonGenerator gen, SerializerProvider provider) throws IOException {
        if (t == null) { gen.writeObject(t); return; }
        if (!MaskContextHolder.getMask()) { gen.writeObject(t); return; }
        MaskSetting setting = MaskContextHolder.getMaskSetting();
        List<DesensitizationRuleCreate> rules = setting.getRules();
        if (CollectionUtils.isEmpty(rules)) { gen.writeObject(t); return; }
        switch (type) {
            case NAME: {
                String s = (String) t;
                DesensitizationRuleCreate rule = rules.stream()
                    .filter(r -> Objects.equals(r.getName(), "name"))
                    .findFirst().orElse(null);
                gen.writeString(MaskUtils.commonMask(s, rule));
                break;
            }
            case ID_CARD: {
                String s = (String) t;
                DesensitizationRuleCreate rule = rules.stream()
                    .filter(r -> Objects.equals(r.getName(), "idCard"))
                    .findFirst().orElse(null);
                gen.writeString(MaskUtils.commonMask(s, rule));
                break;
            }
            case MOBILE: {
                String s = (String) t;
                DesensitizationRuleCreate rule = rules.stream()
                    .filter(r -> Objects.equals(r.getName(), "mobile"))
                    .findFirst().orElse(null);
                gen.writeString(MaskUtils.commonMask(s, rule));
                break;
            }
            case ADDRESS: {
                String s = (String) t;
                DesensitizationRuleCreate rule = rules.stream()
                    .filter(r -> Objects.equals(r.getName(), "address"))
                    .findFirst().orElse(null);
                gen.writeString(MaskUtils.commonMask(s, rule));
                break;
            }
            case CUSTOM_FIELD: {
                Map<String, String> map = (Map<String, String>) t;
                map.forEach((k, v) -> {
                    String maskKey = k;
                    if (k.contains("|")) maskKey = k.substring(0, k.indexOf("|"));
                    if (ADDRESS.contains(k)) maskKey = "address";
                    DesensitizationRuleCreate rule = null;
                    for (DesensitizationRuleCreate r : rules) {
                        if (Objects.equals(r.getName(), maskKey)) { rule = r; break; }
                    }
                    String masked = MaskUtils.commonMask(v, rule);
                    map.put(k, masked);
                });
                gen.writeObject(map);
                break;
            }
        }
    }

    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty prop) throws JsonMappingException {
        if (prop == null) return prov.findNullValueSerializer(prop);
        if (prop.getType().getRawClass() == String.class || prop.getType().getRawClass() == Map.class) {
            MaskField mf = prop.getAnnotation(MaskField.class);
            if (mf == null) mf = prop.getContextAnnotation(MaskField.class);
            if (mf != null) return new MaskSerializer<>(mf.value());
        }
        return prov.findValueSerializer(prop.getType(), prop);
    }

    public MaskSerializer() {}
    public MaskSerializer(MaskEnum type) { this.type = type; }
}

MaskUtils (core masking logic)

public class MaskUtils {
    public static String commonMask(String text, DesensitizationRuleCreate rule) {
        if (StringUtils.isBlank(text) || rule == null) return text;
        int length = text.length();
        Integer type = rule.getType(); // 0 hide, 1 show
        Integer scope = rule.getScope(); // 0 head, 1 middle, -1 tail, 2 all, 3 range
        Integer count = rule.getCount();
        Integer start = rule.getStart();
        Integer end = rule.getEnd();
        try {
            StringBuilder sb = new StringBuilder();
            if (scope == 0) { // head
                for (int i = 0; i < length; i++) {
                    if (i < count) sb.append(type == 0 ? "*" : text.charAt(i));
                    else sb.append(type == 0 ? text.charAt(i) : "*");
                }
            } else if (scope == 1) { // middle
                int mid = length / 2;
                int left = Math.max(0, mid - count / 2);
                int right = mid + count / 2 - (count % 2 == 0 ? 1 : 0);
                for (int i = 0; i < length; i++) {
                    if (i >= left && i <= right) sb.append(type == 0 ? "*" : text.charAt(i));
                    else sb.append(type == 0 ? text.charAt(i) : "*");
                }
            } else if (scope == -1) { // tail
                int n = Math.max(0, length - count);
                for (int i = 0; i < length; i++) {
                    if (i >= n) sb.append(type == 0 ? "*" : text.charAt(i));
                    else sb.append(type == 0 ? text.charAt(i) : "*");
                }
            } else if (scope == 2) { // all
                for (int i = 0; i < length; i++) sb.append(type == 0 ? "*" : text.charAt(i));
            } else if (scope == 3) { // range
                for (int i = 0; i < length; i++) {
                    if (i >= start - 1 && i <= end - 1) sb.append(type == 0 ? "*" : text.charAt(i));
                    else sb.append(type == 0 ? text.charAt(i) : "*");
                }
            }
            return sb.toString();
        } catch (Exception e) {
            log.error("Desensitization failed:", e);
            return text;
        }
    }
    // Individual helper methods (chineseName, idCardNum, mobilePhone, etc.) are omitted for brevity.
}

Usage Example

@Data
public class CaseInfoVO extends Base {
    @ApiModelProperty(value = "姓名")
    @MaskField(MaskEnum.NAME)
    private String name;

    @ApiModelProperty(value = "身份证号码")
    @MaskField(MaskEnum.ID_CARD)
    private String idCard;

    @ApiModelProperty(value = "电话号码")
    @MaskField(MaskEnum.MOBILE)
    private String mobile;

    @ApiModelProperty(value = "自定义字段")
    @MaskField(MaskEnum.CUSTOM_FIELD)
    private Map<String, String> fields;
}

When the controller returns a CaseInfoVO instance, the serializer automatically masks the annotated fields according to the current configuration stored in Redis.

3. Summary

The article presented the background of data‑masking requirements, explored several implementation options, and settled on a Spring Boot + Jackson custom serializer approach. By leveraging a Redis‑backed context, role‑based and department‑based switches, and a flexible rule engine, the solution achieves high performance, zero intrusion into business logic, and full runtime configurability. The provided utility class ( MaskUtils) and the example code demonstrate a complete, production‑ready way to protect sensitive user information.

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.

backendJavadynamic-configurationcustom-annotationHutooljacksondata-desensitizationspring-boot
Shepherd Advanced Notes
Written by

Shepherd Advanced Notes

Dedicated to sharing advanced Java technical insights, daily work snippets, and the power of persistent effort.

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.