Backend Development 25 min read

Spring Boot Starter for Automatic Request and Response Encryption/Decryption

This article demonstrates how to create a custom Spring Boot starter that provides automatic request body decryption and response body encryption using Hutool‑crypto, HttpServletRequestWrapper, RequestBodyAdvice, ResponseBodyAdvice, and validation utilities, enabling secure data transmission with timestamp verification.

Top Architect
Top Architect
Top Architect
Spring Boot Starter for Automatic Request and Response Encryption/Decryption

The author, a senior architect, introduces a solution for securing Java micro‑service communication by encrypting outgoing data and decrypting incoming data without writing repetitive code. A generic starter is presented that integrates symmetric AES encryption (via Hutool‑crypto) and supplies reusable components such as a request wrapper, filters, validation utilities, and Spring‑Boot auto‑configuration.

1. Introduction

In typical Java development, services often need to exchange data securely. Rather than manually encrypting request parameters and decrypting responses, a common starter can encapsulate this functionality.

2. Prerequisites

2.1 hutool‑crypto

Hutool‑crypto offers symmetric and asymmetric encryption utilities; the article does not detail its API.

2.2 Request stream can be read only once

When a filter or AOP reads the HttpServletRequest input stream, the stream is exhausted and cannot be read again downstream. The solution is to extend HttpServletRequestWrapper and cache the stream.

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) {
            // cache the stream on first access
            cacheInputStream();
        }
        return new CachedServletInputStream(cachedBytes.toByteArray());
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    /** Cache the request body for repeated reads */
    private void cacheInputStream() throws IOException {
        cachedBytes = new ByteArrayOutputStream();
        IOUtils.copy(super.getInputStream(), cachedBytes);
    }

    /** InputStream built from cached bytes */
    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(); }
    }
}

A Filter replaces the original request with this wrapper so that downstream code can read the body multiple times.

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;
import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;

/**
 * Filter that converts the request stream into a reusable one
 */
@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

To avoid manual parameter checks, Spring Boot validation annotations (@Validated, @Valid) are used. A custom ParamException aggregates field‑level errors.

package xyz.hlh.cryptotest.exception;

import lombok.Getter;
import java.util.List;

@Getter
public class ParamException extends Exception {
    private final List
fieldList;
    private final List
msgList;

    public ParamException(List
fieldList, List
msgList) {
        this.fieldList = fieldList;
        this.msgList = msgList;
    }
}

The ValidationUtils class centralises validation logic and throws ParamException when constraints fail.

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, property validation 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);
        }
    }
}

3. Custom Starter Implementation

The starter consists of three modules:

crypto-common : shared utilities and constants.

crypto-spring-boot-starter : auto‑configuration, properties, advice classes.

crypto-test : demo application.

3.1 Configuration Properties

Properties are defined in crypto.properties and bound to a POJO.

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;
}

The auto‑configuration class creates an AES bean using these values.

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)
        );
    }
}

3.2 Request Decryption Advice

The DecryptRequestBodyAdvice intercepts controller methods annotated with @DecryptionAnnotation . It reads the raw encrypted JSON from the request, decrypts it using AESUtil.decrypt , validates the timestamp (60 s window), and converts the plaintext back to the target object.

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.RequestBase;
import xyz.hlh.crypto.entity.RequestData;
import xyz.hlh.crypto.util.AESUtil;
import javax.servlet.http.HttpServletRequest;
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();
        HttpServletRequest request = ((ServletRequestAttributes) attrs).getRequest();
        RequestData requestData = objectMapper.readValue(request.getInputStream(), RequestData.class);
        if (requestData == null || StringUtils.isBlank(requestData.getText())) {
            throw new ParamException("Parameter error");
        }
        String encrypted = requestData.getText();
        request.setAttribute(CryptoConstant.INPUT_ORIGINAL_DATA, encrypted);
        String decrypted = AESUtil.decrypt(encrypted);
        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 = 60 * 1000L;
            if (Math.abs(System.currentTimeMillis() - ts) > effective) {
                throw new ParamException("Timestamp invalid");
            }
            return result;
        } else {
            throw new ParamException(String.format("Parameter type %s does not extend RequestBase", result.getClass().getName()));
        }
    }

    @SneakyThrows
    @Override
    public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class
> converterType) {
        Class
cls = Class.forName(targetType.getTypeName());
        return cls.newInstance();
    }
}

3.3 Response Encryption Advice

The EncryptResponseBodyAdvice runs on methods annotated with @EncryptionAnnotation . It extracts the response data, injects a timestamp if the data extends RequestBase , converts the data to JSON, checks length, encrypts it with AESUtil.encryptHex , and wraps the ciphertext in a standard Result object.

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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
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.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) {
        // support when method returns Result (or ResponseEntity
) and has @EncryptionAnnotation
        if (returnType.getGenericParameterType() instanceof java.lang.reflect.ParameterizedType) {
            java.lang.reflect.ParameterizedType pt = (java.lang.reflect.ParameterizedType) returnType.getGenericParameterType();
            if (pt.getRawType() == Result.class && returnType.hasMethodAnnotation(EncryptionAnnotation.class)) {
                return true;
            }
            if (pt.getRawType() == ResponseEntity.class) {
                for (Type t : pt.getActualTypeArguments()) {
                    if (t instanceof java.lang.reflect.ParameterizedType) {
                        java.lang.reflect.ParameterizedType inner = (java.lang.reflect.ParameterizedType) t;
                        if (inner.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) || json.length() < 16) {
            throw new RuntimeException("Encryption failed, data too short");
        }
        String encrypted = AESUtil.encryptHex(json);
        return Result.builder()
                .status(body.getStatus())
                .data(encrypted)
                .message(body.getMessage())
                .build();
    }
}

4. Demo Application

The demo defines a Teacher entity extending RequestBase with validation annotations, and a TestController exposing three endpoints:

/get – normal response (no encryption).

/encrypt – response encrypted via @EncryptionAnnotation .

/decrypt – request decrypted via @DecryptionAnnotation .

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;

@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 required")
    @Range(min = 0, max = 150, message = "Age out of range")
    private Integer age;
    @NotNull(message = "Birthday required")
    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 ResponseEntity.ok(Result.success(teacher));
    }

    @PostMapping("/encrypt")
    @EncryptionAnnotation
    public ResponseEntity
> encrypt(@Validated @RequestBody Teacher teacher) {
        return ResponseEntity.ok(Result.success(teacher));
    }

    @PostMapping("/decrypt")
    @DecryptionAnnotation
    public ResponseEntity
> decrypt(@Validated @RequestBody Teacher teacher) {
        return ResponseEntity.ok(Result.success(teacher));
    }
}

Running the application allows clients to send encrypted JSON payloads, have them automatically decrypted, validated, processed, and then receive encrypted responses, all while the framework handles timestamp freshness checks.

backendJavaSpring BootencryptionResponseBodyAdviceRequestBodyAdvice
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.