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.
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_idor
amountto 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
timestampparameter 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
nonceis 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>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.