Backend Development 25 min read

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.

Top Architect
Top Architect
Top Architect
Implementing Request/Response Encryption and Decryption with a Spring Boot Starter

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=testiv1234567890

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

JavaValidationSpring BootEncryptionRequest WrapperStarter
Top Architect
Written by

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.

0 followers
Reader feedback

How this landed with the community

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