Information Security 15 min read

How to Secure API Endpoints: Prevent Tampering and Replay Attacks with SpringBoot

This article explains why public APIs need protection, outlines anti‑tampering and anti‑replay strategies using timestamps and nonces, and provides complete SpringBoot code—including request signing, filter implementation, and Redis utilities—to safeguard API calls from manipulation and replay attacks.

macrozheng
macrozheng
macrozheng
How to Secure API Endpoints: Prevent Tampering and Replay Attacks with SpringBoot

For internet services, exposing APIs to the public network inevitably brings security risks; if an API is unprotected, attackers can call it once they know the address and parameters.

For example, a registration endpoint that sends SMS verification codes can be abused if the SMS API is not secured.

To ensure API security, public APIs must achieve anti‑tampering and anti‑replay protection.

Anti‑Tampering

HTTP is stateless, and the server cannot verify the legitimacy of client requests or parameters.

Consider a recharge API

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

. An attacker who captures the request can modify

user_id

or

amount

to add balance to any account.

Solution

Using HTTPS encrypts the transmission, but attackers can still capture and replay packets, especially if they spoof certificates. Two common approaches are:

Encrypt request data with HTTPS.

Validate request parameters on the server side to prevent tampering.

Implement a signature scheme: the client signs the request parameters with a shared secret key, sending the signature (sign1) together with the request; the server recomputes the signature (sign2) and compares them.

Anti‑Replay

Replay attacks reuse captured request parameters without modification, e.g., repeatedly calling the recharge API with the same parameters.

Consequences include duplicate database entries or overload of slow query interfaces.

Two mitigation strategies are presented.

Timestamp‑Based Scheme

Each request includes a

timestamp

parameter signed together with other parameters. The server rejects requests whose timestamp differs from the current time by more than 60 seconds.

This prevents replay attacks that take longer than 60 seconds, though attacks within that window remain possible.

Nonce + Timestamp Scheme

A

nonce

is a one‑time random string. The server stores the nonce in Redis; if the same nonce appears within the expiration window (e.g., 60 seconds), the request is rejected as a replay.

Workflow:

Check Redis for key

nonce:{nonce}

.

If absent, create the key with the same expiration as the timestamp.

If present, treat the request as a replay.

Code Implementation

The following Java code demonstrates the anti‑tampering and anti‑replay mechanisms using SpringBoot.

1. Request Header Object

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

2. Utility to Extract Request Parameters

<code>@Slf4j
@UtilityClass
public class HttpDataUtil {
    /** post request: get body parameters as 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;
    }
}
</code>

3. Signature Verification Utility

<code>@Slf4j
@UtilityClass
public class SignUtil {
    /** verify signature using nonce, timestamp and sorted parameters */
    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("Calculated 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 for Security Checks

<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;
        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);
    }
}
</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>
backendRedisSpringBootAPI securitySignatureanti-tamperinganti-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.