Information Security 15 min read

How to Secure APIs: Prevent Tampering and Replay Attacks with Spring Boot

This article explains why publicly exposed APIs are vulnerable to tampering and replay attacks, outlines anti‑tampering and anti‑replay strategies such as HTTPS, request signing, timestamp and nonce mechanisms, and provides a complete Spring Boot implementation with Redis support.

macrozheng
macrozheng
macrozheng
How to Secure APIs: Prevent Tampering and Replay Attacks with Spring Boot

Anti‑Tampering

Public APIs are exposed to the internet, making them prone to tampering; an attacker who knows the endpoint and parameters can manipulate requests, e.g., altering a recharge API to add arbitrary balance.

Typical solutions include using HTTPS for encryption, but HTTPS alone cannot prevent replay or tampering if the attacker can capture and reuse the request. Two practical approaches are:

Encrypt request data via HTTPS, making decryption costly for attackers.

Validate request parameters on the server side to detect modifications.

Implementation Steps

Client encrypts parameters with a shared secret key, generates a signature

sign1

, and includes it in the request.

Server receives the request, recomputes the signature

sign2

using the same secret, and compares

sign1

and

sign2

. A mismatch indicates tampering.

Anti‑Replay

Replay attacks reuse a captured request without modification, causing duplicate data or overwhelming slow‑query endpoints.

Two common defenses are:

Timestamp‑based validation: add a

timestamp

parameter, sign it together with other parameters, and reject requests older than a configured window (e.g., 60 seconds).

Nonce‑plus‑timestamp: generate a unique random string (

nonce

) for each request, combine it with the timestamp, and store the nonce in Redis. If the nonce already exists within the window, the request is considered a replay.

Timestamp Solution

Each HTTP request includes a

timestamp

parameter. The server checks the difference between the current time and the timestamp; if it exceeds the allowed interval, the request is rejected.

Nonce + Timestamp Solution

The client creates a

nonce

(a one‑time random string) combined with the timestamp and signs them. The server checks Redis for the key

nonce:{nonce}

:

If the key does not exist, create it with the same expiration as the timestamp window (e.g., 60 s).

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

Code Implementation

The following Java code demonstrates how to implement anti‑tampering and anti‑replay in a Spring Boot application.

1. Request Header Object

<code>@Data
@Builder
public class RequestHeader {
    private String sign;
    private Long timestamp;
    private String nonce;
}</code>

2. Utility to Extract Parameters

<code>@Slf4j
@UtilityClass
public class HttpDataUtil {
    /**
     * Extract POST 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);
    }
    /**
     * Extract GET URL parameters and convert 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;
    }
}
</code>

3. Signature Verification Utility

<code>@Slf4j
@UtilityClass
public class SignUtil {
    /**
     * Verify signature using nonce, timestamp 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);
    }
    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("Server computed signature: {}", paramsSign);
        return requestHeader.getSign().equals(paramsSign);
    }
}
</code>

4. HttpServletRequest Wrapper

<code>public class SignRequestWrapper extends HttpServletRequestWrapper {
    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()));
    }
}
</code>

5. Filter Configuration and Implementation

<code>@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();
    }
}

@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;
        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()) || requestHeader.getTimestamp() == null || 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;
        }
        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);
    }
}
</code>

6. Redis Utility

<code>@Component
public class RedisUtil {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    public boolean hasKey(String key) {
        try {
            return Boolean.TRUE.equals(redisTemplate.hasKey(key));
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    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;
        }
    }
    public boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
}
</code>
JavaRedisSpring BootAPI securityrequest signinganti-replay
macrozheng
Written by

macrozheng

Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.

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.