Information Security 15 min read

How to Secure Public APIs: Prevent Tampering and Replay Attacks with Java

This article explains why exposed web APIs are vulnerable, introduces anti‑tampering and anti‑replay techniques, and provides a complete Spring Boot implementation—including request signing, timestamp and nonce validation, and Redis storage—to protect API endpoints from malicious reuse.

macrozheng
macrozheng
macrozheng
How to Secure Public APIs: Prevent Tampering and Replay Attacks with Java

Why API Security Matters

When an API is exposed to the Internet, it inevitably faces security risks. If an endpoint is publicly accessible, attackers who know the URL and parameters can invoke it, leading to serious problems such as unauthorized SMS verification or balance manipulation.

To secure an API you must achieve two goals: anti‑tampering and anti‑replay .

Anti‑Tampering

HTTP is a stateless protocol, so the server cannot know whether a request is legitimate or whether its parameters have been altered. For example, a recharge endpoint

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

can be abused if an attacker changes

user_id

or

amount

.

How to Solve It

Using HTTPS encrypts the transmission, but attackers can still capture and replay packets or forge certificates. The common practice is two‑fold:

Encrypt request data via HTTPS.

Validate request parameters on the server side to detect 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 recomputes the signature

sign2

using the same secret.

Step 3: If

sign1

and

sign2

differ, the request is considered tampered.

Anti‑Replay (Anti‑Reuse)

Replay attacks reuse a captured request without modification. They can cause duplicate database entries or overload slow‑query interfaces, leading to service degradation.

Two typical defenses are:

Timestamp‑Based Scheme

Each request includes a

timestamp

parameter that is signed together with other parameters. The server checks whether the timestamp is within an acceptable window (e.g., 60 seconds). If the request is older, it is rejected.

This method fails if the attacker replays within the allowed window.

Nonce + Timestamp Scheme

A nonce is a one‑time random string generated per request (e.g., hash of user info, timestamp, and random data). The server stores the nonce in Redis and checks for its existence.

Processing flow:

Check Redis for key

nonce:{nonce}

.

If absent, create the key with an expiration matching the timestamp window (e.g., 60 seconds).

If present, the request is a replay and should be rejected.

Combining nonce and timestamp ensures that a request is valid only once and within the allowed time frame.

Code Implementation

The following Java code demonstrates a complete solution using Spring Boot.

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 {
    /** 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("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

<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;
        }
        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>

Project Source Code

https://github.com/jianzh5/cloud-blog/tree/main/cloud-demo

JavaRedisSpring BootAPI securityanti-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.