How to Secure Spring MVC APIs with Unified Request/Response Encryption

This article walks through implementing symmetric encryption for both request and response bodies in a Spring MVC project, covering requirement analysis, data model design, custom ControllerAdvice for decryption and encryption, serialization challenges with FastJson and Jackson, and final configuration to ensure consistent API output across Android, iOS, and H5 clients.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
How to Secure Spring MVC APIs with Unified Request/Response Encryption

After being assigned a security task, the author outlines the requirements: minimal code changes, symmetric encryption for both GET and POST interfaces, separate keys for H5 and mobile clients, and compatibility with legacy APIs.

Minimize impact on existing business logic.

Use symmetric encryption; provide two key sets for H5 and Android/iOS.

Support both GET and POST; new interfaces need not be backward compatible.

The data model includes a User class and a UserType enum annotated with @JsonFormat. A simple UserController returns a list of users.

@Data
public class User {
    private Integer id;
    private String name;
    private UserType userType = UserType.COMMON;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime registerTime;
}
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum UserType {
    VIP("VIP用户"),
    COMMON("普通用户");
    private String code;
    private String type;
    UserType(String type) { this.code = name(); this.type = type; }
    @Override
    public String toString() { return "{\"code\":\""+name()+"\",\"type\":\""+type+"\"}"; }
}

The encryption flow is handled by two ControllerAdvice classes. SecretRequestAdvice intercepts incoming requests, validates headers, verifies signatures, and decrypts the body using AES.

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class SecretRequestAdvice extends RequestBodyAdviceAdapter {
    @Override
    public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) { return true; }
    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        String httpBody;
        if (Boolean.TRUE.equals(SecretFilter.secretThreadLocal.get())) {
            httpBody = decryptBody(inputMessage);
        } else {
            httpBody = StreamUtils.copyToString(inputMessage.getBody(), Charset.defaultCharset());
        }
        return new SecretHttpMessage(new ByteArrayInputStream(httpBody.getBytes()), inputMessage.getHeaders());
    }
    private String decryptBody(HttpInputMessage inputMessage) throws IOException {
        // header validation omitted for brevity
        String requestBody = StreamUtils.copyToString(inputMessage.getBody(), Charset.defaultCharset());
        // signature verification omitted
        String privateKey = SecretFilter.clientPrivateKeyThreadLocal.get();
        ReqSecret reqSecret = JSON.parseObject(requestBody, ReqSecret.class);
        String data = reqSecret.getData();
        String newSignature = StringUtils.isEmpty(privateKey) ? "" : Md5Utils.genSignature(reqSecret.getTimestamp()+reqSecret.getSalt()+data+privateKey);
        if (!newSignature.equals(reqSecret.getSignature())) { throw new ResultException(SECRET_API_ERROR, "Signature verification failed"); }
        try {
            String decrypt = EncryptUtils.aesDecrypt(data, privateKey);
            return StringUtils.isEmpty(decrypt) ? "{}" : decrypt;
        } catch (Exception e) { log.error("error: ", e); }
        throw new ResultException(SECRET_API_ERROR, "Decryption failed");
    }
}
SecretResponseAdvice

encrypts the response, adds a timestamp, a random salt, and a new signature before sending it back.

@ControllerAdvice
public class SecretResponseAdvice implements ResponseBodyAdvice<Object> {
    private Logger logger = LoggerFactory.getLogger(SecretResponseAdvice.class);
    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) { return true; }
    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest request, ServerHttpResponse response) {
        Boolean respSecret = SecretFilter.secretThreadLocal.get();
        String secretKey = SecretFilter.clientPrivateKeyThreadLocal.get();
        SecretFilter.secretThreadLocal.remove();
        SecretFilter.clientPrivateKeyThreadLocal.remove();
        if (respSecret != null && respSecret) {
            if (o instanceof ResponseBasic) {
                if (SECRET_API_ERROR == ((ResponseBasic) o).getCode()) {
                    return SecretResponseBasic.fail(((ResponseBasic) o).getCode(), ((ResponseBasic) o).getData(), ((ResponseBasic) o).getMsg());
                }
                try {
                    String data = EncryptUtils.aesEncrypt(JSON.toJSONString(o), secretKey);
                    long timestamp = System.currentTimeMillis() / 1000;
                    int salt = EncryptUtils.genSalt();
                    String newSignature = Md5Utils.genSignature(timestamp+""+salt+""+data+secretKey);
                    return SecretResponseBasic.success(data, timestamp, salt, newSignature);
                } catch (Exception e) {
                    logger.error("beforeBodyWrite error:", e);
                    return SecretResponseBasic.fail(SECRET_API_ERROR, "", "Service result data error");
                }
            }
        }
        return o;
    }
}

During testing, the author discovered that FastJson serialized the UserType enum and LocalDateTime fields incorrectly, causing mismatched formats after encryption. Attempts with SerializerFeature.WriteEnumUsingToString and custom toString did not produce the desired JSON structure.

Switching to Jackson solved the enum issue, but the default LocalDateTime serialization produced a verbose object. By configuring a custom ObjectMapper with LocalDateTimeSerializer and LocalDateTimeDeserializer using the pattern yyyy-MM-dd HH:mm:ss, the author achieved the same output as the non‑encrypted version.

String DATE_TIME_FORMATTER = "yyyy-MM-dd HH:mm:ss";
ObjectMapper objectMapper = new Jackson2ObjectMapperBuilder()
    .findModulesViaServiceLoader(true)
    .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMATTER)))
    .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMATTER)))
    .build();

Finally, the SecretResponseAdvice was updated to use the injected ObjectMapper for serialization before encryption, ensuring that the encrypted response matches the original API contract across all client platforms.

Source: https://github.com/boykait/encrypt-demo Original article: juejin.cn/post/7080568585021554718
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.

JavafastjsonJacksonSpring MVCControllerAdvice
Su San Talks Tech
Written by

Su San Talks Tech

Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.

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.