Implementing Request/Response Encryption and Decryption with a Spring Boot Starter
This article explains how to create a reusable Spring Boot starter that automatically encrypts outgoing responses and decrypts incoming requests using AES, provides a request‑wrapper to allow multiple reads of the input stream, and integrates Spring Validation to simplify parameter checking, all illustrated with complete Java code examples.
1. Introduction
In everyday Java development we often need to interact with other systems or micro‑service interfaces. To ensure data transmission security we must encrypt request parameters and decrypt responses, but we want to avoid writing repetitive code.
2. Prerequisite Knowledge
2.1 hutool‑crypto Encryption Utility
hutool‑crypto provides many encryption tools, including symmetric, asymmetric, and digest algorithms.
2.2 Request Stream Can Be Read Only Once
Problem
When a filter or AOP reads the HttpServletRequest input stream, the stream becomes empty for subsequent processing, causing downstream code to receive no data.
Solution
Extend HttpServletRequestWrapper , copy the original stream, and override getInputStream and getReader to return a cached copy, allowing the stream to be read multiple times.
package xyz.hlh.cryptotest.utils;
import org.apache.commons.io.IOUtils;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
/**
* Request stream that supports multiple reads
*/
public class InputStreamHttpServletRequestWrapper extends HttpServletRequestWrapper {
private ByteArrayOutputStream cachedBytes;
public InputStreamHttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public ServletInputStream getInputStream() throws IOException {
if (cachedBytes == null) {
// First time: cache the stream
cacheInputStream();
}
return new CachedServletInputStream(cachedBytes.toByteArray());
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
/** Cache the original input stream */
private void cacheInputStream() throws IOException {
cachedBytes = new ByteArrayOutputStream();
IOUtils.copy(super.getInputStream(), cachedBytes);
}
/** Cached stream implementation */
public static class CachedServletInputStream extends ServletInputStream {
private final ByteArrayInputStream input;
public CachedServletInputStream(byte[] buf) {
input = new ByteArrayInputStream(buf);
}
@Override
public boolean isFinished() { return false; }
@Override
public boolean isReady() { return false; }
@Override
public void setReadListener(ReadListener listener) { }
@Override
public int read() throws IOException { return input.read(); }
}
}Use a Filter to replace the incoming request with the wrapper at the earliest point in the filter chain.
package xyz.hlh.cryptotest.filter;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import xyz.hlh.cryptotest.utils.InputStreamHttpServletRequestWrapper;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* Filter that converts the request stream into a multi‑readable wrapper
*/
@Component
@Order(HIGHEST_PRECEDENCE + 1)
public class HttpServletRequestInputStreamFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
InputStreamHttpServletRequestWrapper wrapper = new InputStreamHttpServletRequestWrapper(httpServletRequest);
chain.doFilter(wrapper, response);
}
}2.3 Spring Boot Validation
SpringBoot‑validation allows declarative validation on request DTOs using annotations such as @Validated or @Valid . Validation errors throw BindException before the controller method is invoked.
For programmatic validation we provide a utility class ValidationUtils that wraps the JSR‑380 validator and throws a custom ParamException containing field‑level error messages.
package xyz.hlh.cryptotest.utils;
import xyz.hlh.cryptotest.exception.CustomizeException;
import xyz.hlh.cryptotest.exception.ParamException;
import javax.validation.*;
import java.util.*;
public class ValidationUtils {
private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
public static void validate(Object object) throws CustomizeException {
Set
> violations = VALIDATOR.validate(object);
throwParamException(violations);
}
// overloads for groups, properties, etc. omitted for brevity
private static void throwParamException(Set
> violations) throws CustomizeException {
if (!violations.isEmpty()) {
List
fields = new ArrayList<>();
List
msgs = new ArrayList<>();
for (ConstraintViolation
v : violations) {
fields.add(v.getPropertyPath().toString());
msgs.add(v.getMessage());
}
throw new ParamException(fields, msgs);
}
}
}2.4 Custom Starter
Steps to create a reusable starter:
Create a factory class that contains the functional code.
Declare an auto‑configuration class that instantiates the beans you want to expose.
Add a spring.factories file under src/main/resources with the key org.springframework.boot.autoconfigure.EnableAutoConfiguration and the fully‑qualified name of the auto‑configuration class as the value.
2.5 RequestBodyAdvice and ResponseBodyAdvice
RequestBodyAdvice processes the incoming JSON, typically used for automatic decryption of request parameters.
ResponseBodyAdvice processes the outgoing JSON, commonly used for encrypting the response payload.
3. Feature Overview
When a request arrives, the encrypted JSON is automatically decrypted before the controller method is executed; the response data is encrypted before being written back to the client. A timestamp field inherited from RequestBase guarantees data freshness (valid for 60 minutes).
4. Implementation Details
Encryption uses AES symmetric encryption provided by hutool‑crypto . The EncryptionAnnotation and DecryptionAnnotation trigger the corresponding advice classes.
5. Code Implementation
5.1 Project Structure
(Image omitted in this summary.)
5.2 crypto‑common
Contains shared utilities such as AESUtil and the request/response wrapper classes.
5.3 crypto‑spring‑boot‑starter
5.3.1 Configuration Properties
# Mode (cn.hutool.crypto.Mode)
crypto.mode=CTS
# Padding (cn.hutool.crypto.Padding)
crypto.padding=PKCS5Padding
# Secret key
crypto.key=testkey123456789
# IV (initialization vector)
crypto.iv=testiv12345678905.3.2 Auto‑configuration
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
xyz.hlh.crypto.config.AppConfig package xyz.hlh.crypto.config;
import cn.hutool.crypto.Mode;
import cn.hutool.crypto.Padding;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import java.io.Serializable;
@Configuration
@ConfigurationProperties(prefix = "crypto")
@PropertySource("classpath:crypto.properties")
@Data
public class CryptConfig implements Serializable {
private Mode mode;
private Padding padding;
private String key;
private String iv;
} package xyz.hlh.crypto.config;
import cn.hutool.crypto.symmetric.AES;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
@Configuration
public class AppConfig {
@Resource
private CryptConfig cryptConfig;
@Bean
public AES aes() {
return new AES(
cryptConfig.getMode(),
cryptConfig.getPadding(),
cryptConfig.getKey().getBytes(StandardCharsets.UTF_8),
cryptConfig.getIv().getBytes(StandardCharsets.UTF_8)
);
}
}5.4 DecryptRequestBodyAdvice (Automatic Request Decryption)
package xyz.hlh.crypto.advice;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.context.request.*;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice;
import xyz.hlh.crypto.annotation.DecryptionAnnotation;
import xyz.hlh.crypto.common.exception.ParamException;
import xyz.hlh.crypto.constant.CryptoConstant;
import xyz.hlh.crypto.entity.*;
import xyz.hlh.crypto.util.AESUtil;
import javax.servlet.*;
import java.io.IOException;
import java.lang.reflect.Type;
@ControllerAdvice
public class DecryptRequestBodyAdvice implements RequestBodyAdvice {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class
> converterType) {
return methodParameter.hasMethodAnnotation(DecryptionAnnotation.class);
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class
> converterType) throws IOException {
return inputMessage; // no change before reading
}
@SneakyThrows
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class
> converterType) {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) attrs;
if (sra == null) {
throw new ParamException("request error");
}
HttpServletRequest request = sra.getRequest();
RequestData requestData = objectMapper.readValue(request.getInputStream(), RequestData.class);
if (requestData == null || StringUtils.isBlank(requestData.getText())) {
throw new ParamException("invalid parameter");
}
String encrypted = requestData.getText();
request.setAttribute(CryptoConstant.INPUT_ORIGINAL_DATA, encrypted);
String decrypted;
try {
decrypted = AESUtil.decrypt(encrypted);
} catch (Exception e) {
throw new ParamException("decryption failed");
}
if (StringUtils.isBlank(decrypted)) {
throw new ParamException("decryption failed");
}
request.setAttribute(CryptoConstant.INPUT_DECRYPT_DATA, decrypted);
Object result = objectMapper.readValue(decrypted, body.getClass());
if (result instanceof RequestBase) {
long ts = ((RequestBase) result).getCurrentTimeMillis();
long effective = 60L * 1000;
long expire = System.currentTimeMillis() - ts;
if (Math.abs(expire) > effective) {
throw new ParamException("timestamp invalid");
}
return result;
} else {
throw new ParamException(String.format("Request type %s does not extend %s", result.getClass().getName(), RequestBase.class.getName()));
}
}
@SneakyThrows
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class
> converterType) {
Class
clazz = Class.forName(targetType.getTypeName());
return clazz.newInstance();
}
}5.5 EncryptResponseBodyAdvice (Automatic Response Encryption)
package xyz.hlh.crypto.advice;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.*;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import xyz.hlh.crypto.annotation.EncryptionAnnotation;
import xyz.hlh.crypto.common.entity.Result;
import xyz.hlh.crypto.common.exception.CryptoException;
import xyz.hlh.crypto.entity.RequestBase;
import xyz.hlh.crypto.util.AESUtil;
import java.lang.reflect.Type;
@ControllerAdvice
public class EncryptResponseBodyAdvice implements ResponseBodyAdvice
> {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType, Class
> converterType) {
// Supports when the controller method returns Result (or ResponseEntity
) and is annotated with @EncryptionAnnotation
Type generic = ((ParameterizedTypeImpl) returnType.getGenericParameterType()).getRawType();
if (generic == Result.class && returnType.hasMethodAnnotation(EncryptionAnnotation.class)) {
return true;
}
if (generic == ResponseEntity.class) {
for (Type t : ((ParameterizedTypeImpl) returnType.getGenericParameterType()).getActualTypeArguments()) {
if (((ParameterizedTypeImpl) t).getRawType() == Result.class && returnType.hasMethodAnnotation(EncryptionAnnotation.class)) {
return true;
}
}
}
return false;
}
@SneakyThrows
@Override
public Result
beforeBodyWrite(Result
body, MethodParameter returnType, MediaType selectedContentType, Class
> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
Object data = body.getData();
if (data == null) {
return body;
}
if (data instanceof RequestBase) {
((RequestBase) data).setCurrentTimeMillis(System.currentTimeMillis());
}
String json = JSONUtil.toJsonStr(data);
if (StringUtils.isBlank(json)) {
return body;
}
if (json.length() < 16) {
throw new CryptoException("encryption failed, data length < 16");
}
String encrypted = AESUtil.encryptHex(json);
return Result.builder()
.status(body.getStatus())
.data(encrypted)
.message(body.getMessage())
.build();
}
}5.6 Example Entity and Controller
package xyz.hlh.crypto.entity;
import lombok.*;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.*;
import java.io.Serializable;
import java.util.Date;
/** Teacher DTO used for validation */
@Data
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class Teacher extends RequestBase implements Serializable {
@NotBlank(message = "Name cannot be blank")
private String name;
@NotNull(message = "Age cannot be null")
@Range(min = 0, max = 150, message = "Age is out of range")
private Integer age;
@NotNull(message = "Birthday cannot be null")
private Date birthday;
} package xyz.hlh.crypto.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import xyz.hlh.crypto.annotation.*;
import xyz.hlh.crypto.common.entity.Result;
import xyz.hlh.crypto.entity.Teacher;
@RestController
public class TestController {
@PostMapping("/get")
public ResponseEntity
> get(@Validated @RequestBody Teacher teacher) {
return success(teacher);
}
@PostMapping("/encrypt")
@EncryptionAnnotation
public ResponseEntity
> encrypt(@Validated @RequestBody Teacher teacher) {
return success(teacher);
}
@PostMapping("/encrypt1")
@EncryptionAnnotation
public Result
encrypt1(@Validated @RequestBody Teacher teacher) {
return success(teacher).getBody();
}
@PostMapping("/decrypt")
@DecryptionAnnotation
public ResponseEntity
> decrypt(@Validated @RequestBody Teacher teacher) {
return success(teacher);
}
}6. Usage
Add the starter as a Maven/Gradle dependency, configure crypto.mode , crypto.padding , crypto.key , and crypto.iv in crypto.properties , and annotate controller methods with @EncryptionAnnotation or @DecryptionAnnotation as needed.
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.