Backend Development 20 min read

Implementing Open API Signature Verification in Spring Boot

This article explains the concept of open APIs and signature verification, outlines the end‑to‑end signing and verification flow, and provides complete Spring Boot code—including configuration properties, a signature manager, custom annotation, AOP aspect, request‑caching filter, and utility class—to securely validate third‑party requests without exposing credentials.

Top Architect
Top Architect
Top Architect
Implementing Open API Signature Verification in Spring Boot

1. Concept

Open API

Open APIs are interfaces that can be called by third‑party systems without authentication credentials, but they usually require a signature to prevent malicious use. The system providing the open API is referred to as the "original system".

Signature Verification

Before invoking an open API, the third‑party system must generate a signature (a string) based on all request parameters according to the original system's rules. The original system validates the signature; only requests with a valid signature are accepted.

2. Signature Verification Call Flow

1. Agree on Signature Algorithm

The caller and the original system negotiate a signature algorithm (e.g., SHA256withRSA ) and a unique identifier (callerID) to distinguish each caller.

2. Issue Asymmetric Key Pair

After agreeing on the algorithm, the original system generates an RSA key pair for each caller. The private key is given to the caller; the public key stays with the original system.

Note: The caller must keep the private key securely on its backend. If the private key leaks, the caller loses trust.

3. Generate Request Parameter Signature

The caller uses the agreed algorithm and private key to sign the request parameters. The original system typically provides a JAR or code snippet to ensure the signing process matches its verification logic.

4. Send Request with Signature

The caller includes the agreed callerID in the path parameters and the generated signature in the request header.

3. Code Design

1. Signature Configuration Class

import cn.hutool.crypto.asymmetric.SignAlgorithm;
import lombok.Data;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Map;

/**
 * Signature related configuration
 */
@Data
@ConditionalOnProperty(value = "secure.signature.enable", havingValue = "true")
@Component
@ConfigurationProperties("secure.signature")
public class SignatureProps {
    private Boolean enable;
    private Map
keyPair;

    @Data
    public static class KeyPairProps {
        private SignAlgorithm algorithm;
        private String publicKeyPath;
        private String publicKey;
        private String privateKeyPath;
        private String privateKey;
    }
}

2. Signature Manager

import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.HexUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.asymmetric.Sign;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import top.ysqorz.signature.model.SignatureProps;
import java.nio.charset.StandardCharsets;

@Component
@ConditionalOnBean(SignatureProps.class)
public class SignatureManager {
    private final SignatureProps signatureProps;

    public SignatureManager(SignatureProps signatureProps) {
        this.signatureProps = signatureProps;
        loadKeyPairByPath();
    }

    /** Verify signature */
    public boolean verifySignature(String callerID, String rawData, String signature) {
        Sign sign = getSignByCallerID(callerID);
        if (ObjectUtils.isEmpty(sign)) {
            return false;
        }
        return sign.verify(rawData.getBytes(StandardCharsets.UTF_8), HexUtil.decodeHex(signature));
    }

    /** Generate signature */
    public String sign(String callerID, String rawData) {
        Sign sign = getSignByCallerID(callerID);
        if (ObjectUtils.isEmpty(sign)) {
            return null;
        }
        return sign.signHex(rawData);
    }

    private Sign getSignByCallerID(String callerID) {
        SignatureProps.KeyPairProps keyPairProps = signatureProps.getKeyPair().get(callerID);
        if (ObjectUtils.isEmpty(keyPairProps)) {
            return null;
        }
        return SecureUtil.sign(keyPairProps.getAlgorithm(), keyPairProps.getPrivateKey(), keyPairProps.getPublicKey());
    }

    private void loadKeyPairByPath() {
        signatureProps.getKeyPair().forEach((key, kp) -> {
            kp.setPublicKey(loadKeyByPath(kp.getPublicKeyPath()));
            kp.setPrivateKey(loadKeyByPath(kp.getPrivateKeyPath()));
            if (ObjectUtils.isEmpty(kp.getPublicKey()) || ObjectUtils.isEmpty(kp.getPrivateKey())) {
                throw new RuntimeException("No public and private key files configured");
            }
        });
    }

    private String loadKeyByPath(String path) {
        if (ObjectUtils.isEmpty(path)) {
            return null;
        }
        return IoUtil.readUtf8(ResourceUtil.getStream(path));
    }
}

3. Custom Verification Annotation

import java.lang.annotation.*;
/** Annotation to mark controller methods that require signature verification */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface VerifySignature { }

4. AOP Implementation of Verification Logic

import cn.hutool.crypto.CryptoException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.util.ContentCachingRequestWrapper;
import top.ysqorz.common.constant.BaseConstant;
import top.ysqorz.config.SpringContextHolder;
import top.ysqorz.exception.auth.AuthorizationException;
import top.ysqorz.exception.param.ParamInvalidException;
import top.ysqorz.signature.model.SignStatusCode;
import top.ysqorz.signature.model.SignatureProps;
import top.ysqorz.signature.util.CommonUtils;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@Aspect
@Component
@Slf4j
@ConditionalOnBean(SignatureProps.class)
public class RequestSignatureAspect implements PointCutDef {
    @Resource
    private SignatureManager signatureManager;

    @Pointcut("@annotation(top.ysqorz.signature.enumeration.VerifySignature)")
    public void annotatedMethod() {}

    @Pointcut("@within(top.ysqorz.signature.enumeration.VerifySignature)")
    public void annotatedClass() {}

    @Before("apiMethod() && (annotatedMethod() || annotatedClass())")
    public void verifySignature() {
        HttpServletRequest request = SpringContextHolder.getRequest();
        String callerID = request.getParameter(BaseConstant.PARAM_CALLER_ID);
        if (ObjectUtils.isEmpty(callerID)) {
            throw new AuthorizationException(SignStatusCode.UNTRUSTED_CALLER);
        }
        String signature = request.getHeader(BaseConstant.X_REQUEST_SIGNATURE);
        if (ObjectUtils.isEmpty(signature)) {
            throw new ParamInvalidException(SignStatusCode.REQUEST_SIGNATURE_INVALID);
        }
        String requestParamsStr = extractRequestParams(request);
        verifySignature(callerID, requestParamsStr, signature);
    }

    public String extractRequestParams(HttpServletRequest request) {
        String body = null;
        if (request instanceof ContentCachingRequestWrapper) {
            ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
            body = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
        }
        Map
paramMap = request.getParameterMap();
        ServletWebRequest webRequest = new ServletWebRequest(request, null);
        Map
uriTemplateVarMap = (Map
) webRequest.getAttribute(
                HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        return CommonUtils.extractRequestParams(body, paramMap, uriTemplateVarMap);
    }

    public void verifySignature(String callerID, String requestParamsStr, String signature) {
        try {
            boolean verified = signatureManager.verifySignature(callerID, requestParamsStr, signature);
            if (!verified) {
                throw new CryptoException("The signature verification result is false.");
            }
        } catch (Exception ex) {
            log.error("Failed to verify signature", ex);
            throw new AuthorizationException(SignStatusCode.REQUEST_SIGNATURE_INVALID);
        }
    }
}

5. Solving Single‑Read Request Body Issue

Spring Boot provides ContentCachingRequestWrapper to cache the request body so it can be read multiple times. A custom filter wraps incoming requests with this wrapper when needed.

import lombok.NonNull;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import top.ysqorz.signature.model.SignatureProps;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@ConditionalOnBean(SignatureProps.class)
public class RequestCachingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        boolean isFirst = !isAsyncDispatch(request);
        HttpServletRequest wrapper = request;
        if (isFirst && !(request instanceof ContentCachingRequestWrapper)) {
            wrapper = new ContentCachingRequestWrapper(request);
        }
        filterChain.doFilter(wrapper, response);
    }
}

6. Registering the Filter

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import top.ysqorz.signature.model.SignatureProps;

@Configuration
public class FilterConfig {
    @Bean
    @ConditionalOnBean(SignatureProps.class)
    public FilterRegistrationBean
requestCachingFilterRegistration(RequestCachingFilter filter) {
        FilterRegistrationBean
bean = new FilterRegistrationBean<>(filter);
        bean.setOrder(1);
        return bean;
    }
}

7. Utility Class for Parameter Extraction

import cn.hutool.core.util.StrUtil;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;

public class CommonUtils {
    /** Concatenate request body, query parameters and path variables into a single string */
    public static String extractRequestParams(@Nullable String body, @Nullable Map
paramMap,
                                             @Nullable Map
uriTemplateVarMap) {
        String paramStr = null;
        if (!ObjectUtils.isEmpty(paramMap)) {
            paramStr = paramMap.entrySet().stream()
                    .sorted(Map.Entry.comparingByKey())
                    .map(e -> {
                        String[] sorted = Arrays.stream(e.getValue()).sorted().toArray(String[]::new);
                        return e.getKey() + "=" + joinStr(",", sorted);
                    })
                    .collect(Collectors.joining("&"));
        }
        String uriVarStr = null;
        if (!ObjectUtils.isEmpty(uriTemplateVarMap)) {
            uriVarStr = joinStr(",", uriTemplateVarMap.values().stream().sorted().toArray(String[]::new));
        }
        return joinStr("#", body, paramStr, uriVarStr);
    }

    public static String joinStr(String delimiter, @Nullable String... strs) {
        if (ObjectUtils.isEmpty(strs)) {
            return StrUtil.EMPTY;
        }
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < strs.length; i++) {
            if (ObjectUtils.isEmpty(strs[i])) continue;
            sb.append(strs[i].trim());
            if (i < strs.length - 1 && !ObjectUtils.isEmpty(strs[i + 1])) {
                sb.append(delimiter);
            }
        }
        return sb.toString();
    }
}

The article concludes with a reminder that the source code is available on GitHub (https://github.com/passerbyYSQ/DemoRepository) and invites readers to discuss, ask questions, or join related communities.

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