How to Secure Your Public APIs: Anti‑Tampering and Anti‑Replay Strategies in Spring Boot

This article explains why publicly exposed APIs are vulnerable, describes the concepts of anti‑tampering and anti‑replay protection, and provides a complete Spring Boot implementation—including request signing, timestamp and nonce validation, and Redis‑based replay detection—to safeguard API endpoints.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
How to Secure Your Public APIs: Anti‑Tampering and Anti‑Replay Strategies in Spring Boot

Hello, I am Su San. For the Internet, any API exposed to the public network inevitably faces security problems. If an API is accessible without protection, attackers who know the endpoint and parameters can invoke it, which can be disastrous.

For example, when a website registers a user, it sends a mobile verification code. If the SMS sending interface is not specially secured, the interface can be abused, causing unknown financial loss.

Anti‑Tampering

HTTP is a stateless protocol; the server does not know whether a client request is legitimate or whether the parameters are correct.

Example: a recharge API that adds balance to a user account.

http://localhost/api/user/recharge?user_id=1001&amount=10

If an attacker captures the request parameters and modifies user_id or amount, they can add balance to any account.

How to Solve

Using HTTPS encrypts the transmission, but attackers can still capture packets and replay them. If a forged certificate is used, HTTPS content can be decrypted.

Common approaches:

Encrypt API data with HTTPS, making decryption costly for attackers.

Validate request parameters on the backend to prevent tampering.

Step 1: The client encrypts the parameters with a shared secret, generates a signature sign1, and includes it in the request.

Step 2: The server receives the request and generates its own signature sign2 using the same secret.

Step 3: The server compares sign1 and sign2; if they differ, the request is considered tampered.

Anti‑Replay

Anti‑replay (or anti‑reuse) means sending the same request parameters without modification. The request is still valid because all parameters match a legitimate request.

Replay attacks can cause two problems:

For insert‑type APIs, massive duplicate or junk data can fill the database.

For query‑type APIs, attackers can repeatedly hit slow queries, exhausting system resources.

Two common solutions:

Timestamp‑Based Scheme

Each HTTP request includes a timestamp parameter, which is signed together with other parameters. The server checks if the timestamp is within an acceptable window (e.g., 60 seconds). If the request is older, it is rejected.

Because replaying a request typically takes longer than 60 seconds, the timestamp becomes invalid. If an attacker modifies the timestamp to the current time, the signature will no longer match because the secret key is unknown.

This method fails if the replay occurs within the 60‑second window.

Nonce + Timestamp Scheme

A nonce is a one‑time random string that must be unique for each request. It can be generated by hashing user information, a timestamp, and a random number.

Server processing flow:

Check Redis for a key nonce:{nonce}. If it does not exist, create it with an expiration time equal to the timestamp window (e.g., 60 s).

If the key already exists, the request is a replay and should be rejected.

Combining nonce and timestamp in the signature ensures that a request is valid only once within the time window, effectively preventing replay attacks.

Code Implementation

Below is a complete Spring Boot implementation of anti‑tampering and anti‑replay protection.

1. Request Header Object

@Data
@Builder
public class RequestHeader {
    private String sign;
    private Long timestamp;
    private String nonce;
}

2. Utility Class to Extract Parameters

@Slf4j
@UtilityClass
public class HttpDataUtil {
    /**
     * POST request: get body parameters and convert to SortedMap
     */
    public SortedMap<String, String> getBodyParams(final HttpServletRequest request) throws IOException {
        byte[] requestBody = StreamUtils.copyToByteArray(request.getInputStream());
        String body = new String(requestBody);
        return JsonUtil.json2Object(body, SortedMap.class);
    }

    /**
     * GET request: convert URL parameters to SortedMap
     */
    public static SortedMap<String, String> getUrlParams(HttpServletRequest request) {
        String param = "";
        SortedMap<String, String> result = new TreeMap<>();
        if (StringUtils.isEmpty(request.getQueryString())) {
            return result;
        }
        try {
            param = URLDecoder.decode(request.getQueryString(), "utf-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        String[] params = param.split("&");
        for (String s : params) {
            String[] array = s.split("=");
            result.put(array[0], array[1]);
        }
        return result;
    }
}

3. Signature Verification Utility

@Slf4j
@UtilityClass
public class SignUtil {
    /**
     * Verify signature using timestamp, nonce and sorted parameters
     */
    @SneakyThrows
    public boolean verifySign(SortedMap<String, String> map, RequestHeader requestHeader) {
        String params = requestHeader.getNonce() + requestHeader.getTimestamp() + JsonUtil.object2Json(map);
        return verifySign(params, requestHeader);
    }

    /**
     * Verify signature directly from a string
     */
    public boolean verifySign(String params, RequestHeader requestHeader) {
        log.debug("Client signature: {}", requestHeader.getSign());
        if (StringUtils.isEmpty(params)) {
            return false;
        }
        log.info("Client payload: {}", params);
        String paramsSign = DigestUtils.md5DigestAsHex(params.getBytes()).toUpperCase();
        log.info("Calculated signature: {}", paramsSign);
        return requestHeader.getSign().equals(paramsSign);
    }
}

4. HttpServletRequest Wrapper

public class SignRequestWrapper extends HttpServletRequestWrapper {
    // Save the request body stream
    private byte[] requestBody = null;

    public SignRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        requestBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);
        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 bais.read(); }
        };
    }

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

5. Filter Configuration

@Configuration
public class SignFilterConfiguration {
    @Value("${sign.maxTime}")
    private String signMaxTime;

    private Map<String, String> initParametersMap = new HashMap<>();

    @Bean
    public FilterRegistrationBean contextFilterRegistrationBean() {
        initParametersMap.put("signMaxTime", signMaxTime);
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(signFilter());
        registration.setInitParameters(initParametersMap);
        registration.addUrlPatterns("/sign/*");
        registration.setName("SignFilter");
        registration.setOrder(1);
        return registration;
    }

    @Bean
    public Filter signFilter() {
        return new SignFilter();
    }
}

6. Security Filter

@Slf4j
public class SignFilter implements Filter {
    @Resource
    private RedisUtil redisUtil;

    private Long signMaxTime;
    private static final String NONCE_KEY = "x-nonce-";

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        log.info("Filtering URL:{}", httpRequest.getRequestURI());

        HttpServletRequestWrapper requestWrapper = new SignRequestWrapper(httpRequest);
        RequestHeader requestHeader = RequestHeader.builder()
                .nonce(httpRequest.getHeader("x-Nonce"))
                .timestamp(Long.parseLong(httpRequest.getHeader("X-Time")))
                .sign(httpRequest.getHeader("X-Sign"))
                .build();

        if (StringUtils.isEmpty(requestHeader.getSign()) || ObjectUtils.isEmpty(requestHeader.getTimestamp()) || StringUtils.isEmpty(requestHeader.getNonce())) {
            responseFail(httpResponse, ReturnCode.ILLEGAL_HEADER);
            return;
        }

        long now = System.currentTimeMillis() / 1000;
        if (now - requestHeader.getTimestamp() > signMaxTime) {
            responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
            return;
        }

        boolean nonceExists = redisUtil.hasKey(NONCE_KEY + requestHeader.getNonce());
        if (nonceExists) {
            responseFail(httpResponse, ReturnCode.REPLAY_ERROR);
            return;
        } else {
            redisUtil.set(NONCE_KEY + requestHeader.getNonce(), requestHeader.getNonce(), signMaxTime);
        }

        boolean accept;
        SortedMap<String, String> paramMap;
        switch (httpRequest.getMethod()) {
            case "GET":
                paramMap = HttpDataUtil.getUrlParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, requestHeader);
                break;
            case "POST":
                paramMap = HttpDataUtil.getBodyParams(requestWrapper);
                accept = SignUtil.verifySign(paramMap, requestHeader);
                break;
            default:
                accept = true;
                break;
        }
        if (accept) {
            filterChain.doFilter(requestWrapper, servletResponse);
        } else {
            responseFail(httpResponse, ReturnCode.ARGUMENT_ERROR);
        }
    }

    private void responseFail(HttpServletResponse httpResponse, ReturnCode returnCode) {
        ResultData<Object> resultData = ResultData.fail(returnCode.getCode(), returnCode.getMessage());
        WebUtils.writeJson(httpResponse, resultData);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String signTime = filterConfig.getInitParameter("signMaxTime");
        signMaxTime = Long.parseLong(signTime);
    }
}

7. Redis Utility

@Component
public class RedisUtil {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * Check if a key exists
     */
    public boolean hasKey(String key) {
        try {
            return Boolean.TRUE.equals(redisTemplate.hasKey(key));
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * Set a value with expiration time (seconds)
     */
    public boolean set(String key, Object value, long time) {
        try {
            if (time > 0) {
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else {
                set(key, value);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * Set a value without expiration
     */
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}
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.

JavaredisSpring BootAPI Securitysignatureanti-tamperingAnti-replay
Su San Talks Tech
Written by

Su San Talks Tech

Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.

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.