How to Secure Spring MVC APIs with Request/Response Encryption Using ControllerAdvice
This article walks through a real‑world scenario of adding symmetric encryption to Spring MVC endpoints for Android, iOS and H5 clients, detailing the requirements, code implementation of request and response interceptors, serialization pitfalls with enums and LocalDateTime, and the final solution using Jackson's ObjectMapper to keep encrypted and non‑encrypted responses consistent.
When a security issue arose on a project, the team needed to encrypt all API traffic for Android, iOS and H5 clients while minimizing code changes and keeping backward compatibility for existing business logic.
Requirements
Minimal impact on existing code.
Use symmetric encryption because of tight schedule.
Separate key sets for H5 (lower security) and mobile apps.
Maintain compatibility with legacy GET/POST interfaces; new interfaces do not need backward compatibility.
Both request and response bodies must be encrypted.
Requirement Analysis
Unified request/response interception can be achieved with Spring MVC ControllerAdvice.
FastJson provides a ready‑made AES encryption demo, but the project already uses Jackson for serialization.
Domain Model
@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 + "\"}";
}
}Simple User List Endpoint
@RestController
@RequestMapping({"/user", "/secret/user"})
public class UserController {
@RequestMapping("/list")
ResponseEntity<List<User>> listUser() {
List<User> users = new ArrayList<>();
User u = new User();
u.setId(1);
u.setName("boyka");
u.setRegisterTime(LocalDateTime.now());
u.setUserType(UserType.COMMON);
users.add(u);
ResponseEntity<List<User>> response = new ResponseEntity<>();
response.setCode(200);
response.setData(users);
response.setMsg("用户列表查询成功");
return response;
}
}Calling localhost:8080/user/list returns a JSON payload with code 200, the user list, and a success message.
Request Interception (Decryption)
@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 {
InputStream encryptStream = inputMessage.getBody();
String requestBody = StreamUtils.copyToString(encryptStream, Charset.defaultCharset());
HttpHeaders headers = inputMessage.getHeaders();
if (CollectionUtils.isEmpty(headers.get("clientType")) ||
CollectionUtils.isEmpty(headers.get("timestamp")) ||
CollectionUtils.isEmpty(headers.get("salt")) ||
CollectionUtils.isEmpty(headers.get("signature"))) {
throw new ResultException(SECRET_API_ERROR,
"请求解密参数错误,clientType、timestamp、salt、signature等参数传递是否正确传递");
}
String timestamp = String.valueOf(Objects.requireNonNull(headers.get("timestamp")).get(0));
String salt = String.valueOf(Objects.requireNonNull(headers.get("salt")).get(0));
String signature = String.valueOf(Objects.requireNonNull(headers.get("signature")).get(0));
String privateKey = SecretFilter.clientPrivateKeyThreadLocal.get();
ReqSecret reqSecret = JSON.parseObject(requestBody, ReqSecret.class);
String data = reqSecret.getData();
String newSignature = "";
if (!StringUtils.isEmpty(privateKey)) {
newSignature = Md5Utils.genSignature(timestamp + salt + data + privateKey);
}
if (!newSignature.equals(signature)) {
throw new ResultException(SECRET_API_ERROR, "验签失败,请确认加密方式是否正确");
}
try {
String decrypt = EncryptUtils.aesDecrypt(data, privateKey);
if (StringUtils.isEmpty(decrypt)) {
decrypt = "{}";
}
return decrypt;
} catch (Exception e) {
log.error("error: ", e);
}
throw new ResultException(SECRET_API_ERROR, "解密失败");
}
}Response Interception (Encryption)
@ControllerAdvice
public class SecretResponseAdvice implements ResponseBodyAdvice {
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 dataNew = timestamp + "" + salt + "" + data + secretKey;
String newSignature = Md5Utils.genSignature(dataNew);
return SecretResponseBasic.success(data, timestamp, salt, newSignature);
} catch (Exception e) {
logger.error("beforeBodyWrite error:", e);
return SecretResponseBasic.fail(SECRET_API_ERROR, "", "服务端处理结果数据异常");
}
}
}
return o;
}
}Running the demo shows the full request‑response flow:
Request:
POST localhost:8080/secret/user/list
Headers:
Content-Type: application/json
clientType: ANDROID
timestamp: 1648308048
salt: 123456
signature: 55efb04a83ca083dd1e6003cde127c45
Body (encrypted):
{ "data": "1ZBecdnDuMocxAiW9UtBrJzlvVbueP9K0MsIxQccmU3OPG92oRinVm0GxBwdlXXJ" }
Response (encrypted):
{ "data": "fxHYvnIE54eAXDbErdrDryEsIYNvsOOkyEKYB1iBcre/QU1wMowHE2BNX/je6OP3NlsCtAeDqcp7J1N332el8q2FokixLvdxAPyW5Un9JiT0LQ3MB8p+nN23pTSIvh9VS92lCA8KULWg2nViSFL5X1VwKrF0K/dcVVZnpw5h227UywP6ezSHjHdA+Q0eKZFGTEv3IzNXWqq/otx5fl1gKQ==", "code":200, "signature":"aa61f19da0eb5d99f13c145a40a7746b", "timestamp":1648480034, "salt":632648 }
Decrypted response body:
{ "code":200, "data":[{ "id":1, "name":"boyka", "userType":{"code":"COMMON","type":"普通用户"}, "registerTime":"2022-03-27T00:19:43.699" }], "msg":"用户列表查询成功", "salt":0 }Serialization Issue Discovered
After encryption the userType field serialized correctly, but registerTime turned into a complex Jackson object structure instead of the expected yyyy‑MM‑dd HH:mm:ss string.
The team first tried FastJson SerializerFeature.WriteEnumUsingToString and custom toString() on the enum, which did not affect the date format. The root cause was that JSON.toJSONString(o) used FastJson's default serializer, which does not respect the @JsonFormat annotation on LocalDateTime.
Switch to Jackson for Serialization
Replacing the FastJson call with Jackson's ObjectMapper.writeValueAsString(o) produced the correct enum representation and a proper date string.
String data = EncryptUtils.aesEncrypt(new ObjectMapper().writeValueAsString(o), secretKey);Custom Date Formatting with Jackson
To guarantee the exact yyyy‑MM‑dd HH:mm:ss format across the whole service, the author configured a dedicated ObjectMapper bean:
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();Using this mapper inside SecretResponseAdvice restored the response format to match the non‑encrypted version:
{ "code":200, "data":[{ "id":1, "name":"boyka", "userType":{"code":"COMMON","type":"普通用户"}, "registerTime":"2022-03-29 22:57:33" }], "msg":"用户列表查询成功" }Remaining Considerations
Different business scenarios may require multiple date patterns (e.g., yyyy‑MM‑dd hh:mm:ss or yyyy‑MM‑dd); a global mapper may need further customization.
GET request encryption and cross‑origin issues still need to be addressed in future work.
https://github.com/boykait/encrypt-demo
The article therefore demonstrates a complete end‑to‑end approach for encrypting Spring MVC APIs, from requirement gathering through concrete code, debugging of serialization problems, and final configuration that aligns encrypted responses with the original API contract.
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.
Architect
Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.
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.
