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.
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=10can be abused if an attacker changes
user_idor
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
sign2using the same secret.
Step 3: If
sign1and
sign2differ, 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
timestampparameter 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
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.