Elegant Ways to Boost API Data Security in Spring Boot

The article explains how to protect Spring Boot APIs from unauthorized data access by adding data‑level permission checks, encrypting request parameters with AES and RSA, signing requests with SHA1WithRSA, and encapsulating the logic in a reusable @ApiSecurity annotation and AOP aspect.

Shepherd Advanced Notes
Shepherd Advanced Notes
Shepherd Advanced Notes
Elegant Ways to Boost API Data Security in Spring Boot

Problem Statement

When a client can control the id parameter of a delete API (e.g., delete from table where id=?), a malicious user can delete other users' records by guessing sequential IDs. Adding a data‑level condition ( and user_id = userId) prevents the attack but makes business logic more complex.

Design Goal

Increase the difficulty of calling the API from the public network by encrypting request parameters and signing the request, without exposing raw interface definitions.

Encryption Architecture

Both asymmetric (RSA) and symmetric (AES) algorithms are used. AES encrypts the JSON payload quickly; RSA encrypts the AES key for secure key exchange.

Request Encryption Process

Client generates a random AES key ( aesKey).

Client encrypts the request body with AES, then Base64‑encodes it as data=base64(AES(json)).

Client encrypts the AES key with the server’s RSA public key, then Base64‑encodes it as key=base64(RSA(aesKey)).

The client sends a JSON object containing appId, key, data, sign, timestamp, and nonce (see ApiSecurityParam definition below).

Server decrypts key with its RSA private key to obtain the AES key.

Server decrypts data with the AES key, parses the JSON, and maps it to the controller method argument.

After business processing, the server encrypts the response using the same AES‑RSA flow.

Client decrypts the response using the same steps.

Response Encryption

The response encryption is performed by a ResponseBodyAdvice implementation that generates a fresh AES key, encrypts the response data, encrypts the AES key with RSA, and returns a JSON object with key and data.

Signature Mechanism

Signature prevents tampering and replay attacks.

Signature Generation (client side)

Convert the request bean to a sorted map ( sortMap).

Concatenate content = sortMap + nonce + timestamp.

Sign content with SHA1WithRSA using the client’s private key to produce sign.

Signature Verification (server side)

Extract sign, nonce, and timestamp from the request body or HTTP headers ( X‑Sign, X‑Nonce, X‑Timestamp).

Reject the request if any of these values are missing.

Validate that timestamp is within the configured validity period (e.g., apiSecurityProperties.getValidTime() seconds).

Check that nonce does not already exist in Redis (key prefix x-nonce-); reject duplicates.

Recreate content using the same sorting rule and the received nonce and timestamp.

Verify the signature with the RSA public key; reject if verification fails.

Store the nonce in Redis with the same TTL as the signature validity.

Annotation‑Driven Configuration

A custom annotation @ApiSecurity controls encryption and signing per controller or method.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
public @interface ApiSecurity {
    @Alias("isSign")
    boolean value() default true; // alias for isSign
    @Alias("value")
    boolean isSign() default true; // enable signature verification
    @Alias("decryptRequest")
    boolean decryptRequest() default false; // enable request decryption
    @Alias("encryptResponse")
    boolean encryptResponse() default false; // enable response encryption
}

Example usage:

@PostMapping("/security")
@ApiSecurity(encryptResponse = true, decryptRequest = true)
public User testApiSecurity(@RequestBody User user) {
    System.out.println(user);
    return user;
}

Data Transfer Object

public class ApiSecurityParam {
    private String appId;      // application identifier
    private String key;       // RSA‑encrypted AES key (Base64)
    private String data;      // AES‑encrypted JSON payload (Base64)
    private String sign;      // RSA‑SHA1 signature
    private String timestamp; // request timestamp
    private String nonce;     // unique request identifier
}

Handling Single‑Read InputStream

Spring’s HttpServletRequest.getInputStream() can be read only once. To allow decryption before the controller reads the body, a wrapper caches the raw request body.

public class RequestBodyWrapper extends HttpServletRequestWrapper {
    private String body;
    public RequestBodyWrapper(HttpServletRequest request) throws IOException {
        super(request);
        body = new String(StreamUtils.copyToByteArray(request.getInputStream()), StandardCharsets.UTF_8);
    }
    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream bis = new ByteArrayInputStream(body.getBytes("UTF-8"));
        return new ServletInputStream() {
            @Override public boolean isFinished() { return false; }
            @Override public boolean isReady() { return false; }
            @Override public void setReadListener(ReadListener readListener) {}
            @Override public int read() throws IOException { return bis.read(); }
        };
    }
    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
    public String getBody() { return body; }
    public void setBody(String body) { this.body = body; }
}

A servlet filter replaces the original request with this wrapper so downstream components can read the body multiple times.

@Slf4j
public class BodyTransferFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        RequestBodyWrapper wrapper = null;
        try {
            HttpServletRequest req = (HttpServletRequest) request;
            wrapper = new RequestBodyWrapper(req);
        } catch (Exception e) {
            log.warn("requestBodyWrapper Error:", e);
        }
        chain.doFilter(wrapper == null ? request : wrapper, response);
    }
}

Aspect for Decryption and Verification

The ApiSecurityAspect intercepts methods annotated with @ApiSecurity. It enforces POST requests, performs request decryption, signature verification, and passes the decrypted argument to the original method.

@Aspect
@Slf4j
@Order(value = OrderConstant.AOP_API_DECRYPT)
public class ApiSecurityAspect {
    @Resource private ApiSecurityProperties apiSecurityProperties;
    @Resource private StringRedisTemplate stringRedisTemplate;
    private static final String NONCE_KEY = "x-nonce-";

    @Pointcut("execution(* com.plasticene..controller..*(..)) && (@annotation(com.plasticene.boot.web.core.anno.ApiSecurity) || @target(com.plasticene.boot.web.core.anno.ApiSecurity))")
    public void securityPointcut() {}

    @Around("securityPointcut()")
    public Object aroundApiSecurity(ProceedingJoinPoint joinPoint) throws Throwable {
        ApiSecurity apiSecurity = getApiSecurity(joinPoint);
        boolean isSign = apiSecurity.isSign();
        boolean decryptRequest = apiSecurity.decryptRequest();
        HttpServletRequest request = getRequest();
        if (!"POST".equals(request.getMethod())) {
            throw new BizException("Only POST interfaces can be encrypted and signed");
        }
        Object[] args = joinPoint.getArgs();
        Object[] newArgs = args;
        ApiSecurityParam apiSecurityParam = new ApiSecurityParam();
        if (decryptRequest) {
            if (args.length > 1) {
                throw new BizException("Encrypted API methods support only one parameter");
            }
            if (args.length == 1) {
                RequestBodyWrapper wrapper = request instanceof RequestBodyWrapper ? (RequestBodyWrapper) request : new RequestBodyWrapper(request);
                String body = wrapper.getBody();
                apiSecurityParam = JSONObject.parseObject(body, ApiSecurityParam.class);
                String aesKey = RSAUtil.decryptByPrivateKey(apiSecurityParam.getKey(), apiSecurityProperties.getRsaPrivateKey());
                String data = AESUtil.decrypt(apiSecurityParam.getData(), aesKey);
                Class<?> paramClass = args[0].getClass();
                Object realParam = JSONObject.parseObject(data, paramClass);
                newArgs = new Object[]{realParam};
            }
        }
        if (isSign) {
            verifySign(request, newArgs.length == 0 ? null : newArgs[0], apiSecurityParam);
        }
        return joinPoint.proceed(newArgs);
    }

    void verifySign(HttpServletRequest request, Object paramObj, ApiSecurityParam apiSecurityParam) {
        String sign = StringUtils.defaultIfBlank(apiSecurityParam.getSign(), request.getHeader("X-Sign"));
        if (StringUtils.isBlank(sign)) {
            throw new BizException("Signature cannot be empty");
        }
        String nonce = StringUtils.defaultIfBlank(apiSecurityParam.getNonce(), request.getHeader("X-Nonce"));
        if (StringUtils.isBlank(nonce)) {
            throw new BizException("Nonce cannot be empty");
        }
        String timestamp = StringUtils.defaultIfBlank(apiSecurityParam.getTimestamp(), request.getHeader("X-Timestamp"));
        if (StringUtils.isBlank(timestamp)) {
            throw new BizException("Timestamp cannot be empty");
        }
        long t;
        try { t = Long.parseLong(timestamp); } catch (Exception e) { throw new BizException("Invalid timestamp"); }
        long now = System.currentTimeMillis() / 1000;
        if (now - t > apiSecurityProperties.getValidTime()) {
            throw new BizException("Signature expired");
        }
        if (stringRedisTemplate.hasKey(NONCE_KEY + nonce)) {
            throw new BizException("Nonce already exists");
        }
        SortedMap sortedMap = SignUtil.beanToMap(paramObj);
        String content = SignUtil.getContent(sortedMap, nonce, timestamp);
        boolean ok = RSAUtil.verifySignByPublicKey(content, sign, apiSecurityProperties.getRsaPublicKey());
        if (!ok) {
            throw new BizException("Signature verification failed");
        }
        stringRedisTemplate.opsForValue().set(NONCE_KEY + nonce, "1", apiSecurityProperties.getValidTime(), TimeUnit.SECONDS);
    }

    private HttpServletRequest getRequest() {
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attrs.getRequest();
    }

    private ApiSecurity getApiSecurity(JoinPoint joinPoint) {
        MethodSignature ms = (MethodSignature) joinPoint.getSignature();
        Method method = ms.getMethod();
        ApiSecurity apiSecurity = method.getAnnotation(ApiSecurity.class);
        if (apiSecurity == null) {
            apiSecurity = method.getDeclaringClass().getAnnotation(ApiSecurity.class);
        }
        return apiSecurity;
    }
}

Response Encryption Advice

@RestControllerAdvice
@Slf4j
public class ResponseResultBodyAdvice implements ResponseBodyAdvice<Object> {
    @Resource private ObjectMapper objectMapper;
    @Resource private ApiSecurityProperties apiSecurityProperties;

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseResultBody.class)
                || returnType.hasMethodAnnotation(ResponseResultBody.class)
                || AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ApiSecurity.class)
                || returnType.hasMethodAnnotation(ApiSecurity.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        Method method = returnType.getMethod();
        Class<?> returnClass = method.getReturnType();
        boolean enable = apiSecurityProperties.getEnable();
        ApiSecurity apiSecurity = method.getAnnotation(ApiSecurity.class);
        if (apiSecurity == null) {
            apiSecurity = method.getDeclaringClass().getAnnotation(ApiSecurity.class);
        }
        if (enable && apiSecurity != null && apiSecurity.encryptResponse() && body != null) {
            if (body instanceof ResponseVO) {
                body = ((ResponseVO) body).getData();
            }
            JSONObject jsonObject = encryptResponse(body);
            return jsonObject;
        } else {
            if (body instanceof String || returnClass.equals(String.class)) {
                String value = objectMapper.writeValueAsString(ResponseVO.success(body));
                return value;
            }
            if (body instanceof ResponseVO) {
                return body;
            }
        }
        return ResponseVO.success(body);
    }

    private JSONObject encryptResponse(Object result) {
        String aesKey = AESUtil.generateAESKey();
        String content = JSONObject.toJSONString(result);
        String data = AESUtil.encrypt(content, aesKey);
        String key = RSAUtil.encryptByPublicKey(aesKey, apiSecurityProperties.getRsaPublicKey());
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("key", key);
        jsonObject.put("data", data);
        return jsonObject;
    }
}

Key Points and Constraints

Only POST methods are processed; other HTTP methods are rejected.

Encrypted APIs support a single request parameter; multiple parameters are not allowed because decryption yields a single JSON object.

Nonce is stored in Redis with a TTL equal to the signature validity period to prevent replay attacks.

Timestamp validation uses server time in seconds; requests older than the configured validTime are rejected.

Signature verification uses RSA public key and SHA1WithRSA algorithm.

Repository

Full source code: https://github.com/plasticene/plasticene-boot-starter-parent/tree/main/plasticene-boot-starter-web

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.

AOPSpring BootAnnotationEncryptionAPI SecuritySigning
Shepherd Advanced Notes
Written by

Shepherd Advanced Notes

Dedicated to sharing advanced Java technical insights, daily work snippets, and the power of persistent effort.

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.