How to Secure Your Public APIs: Anti‑Tampering and Anti‑Replay Strategies in Spring Boot
This article explains why publicly exposed APIs are vulnerable, describes the concepts of anti‑tampering and anti‑replay protection, and provides a complete Spring Boot implementation—including request signing, timestamp and nonce validation, and Redis‑based replay detection—to safeguard API endpoints.
Hello, I am Su San. For the Internet, any API exposed to the public network inevitably faces security problems. If an API is accessible without protection, attackers who know the endpoint and parameters can invoke it, which can be disastrous.
For example, when a website registers a user, it sends a mobile verification code. If the SMS sending interface is not specially secured, the interface can be abused, causing unknown financial loss.
Anti‑Tampering
HTTP is a stateless protocol; the server does not know whether a client request is legitimate or whether the parameters are correct.
Example: a recharge API that adds balance to a user account.
http://localhost/api/user/recharge?user_id=1001&amount=10If an attacker captures the request parameters and modifies user_id or amount, they can add balance to any account.
How to Solve
Using HTTPS encrypts the transmission, but attackers can still capture packets and replay them. If a forged certificate is used, HTTPS content can be decrypted.
Common approaches:
Encrypt API data with HTTPS, making decryption costly for attackers.
Validate request parameters on the backend to prevent 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 generates its own signature sign2 using the same secret.
Step 3: The server compares sign1 and sign2; if they differ, the request is considered tampered.
Anti‑Replay
Anti‑replay (or anti‑reuse) means sending the same request parameters without modification. The request is still valid because all parameters match a legitimate request.
Replay attacks can cause two problems:
For insert‑type APIs, massive duplicate or junk data can fill the database.
For query‑type APIs, attackers can repeatedly hit slow queries, exhausting system resources.
Two common solutions:
Timestamp‑Based Scheme
Each HTTP request includes a timestamp parameter, which is signed together with other parameters. The server checks if the timestamp is within an acceptable window (e.g., 60 seconds). If the request is older, it is rejected.
Because replaying a request typically takes longer than 60 seconds, the timestamp becomes invalid. If an attacker modifies the timestamp to the current time, the signature will no longer match because the secret key is unknown.
This method fails if the replay occurs within the 60‑second window.
Nonce + Timestamp Scheme
A nonce is a one‑time random string that must be unique for each request. It can be generated by hashing user information, a timestamp, and a random number.
Server processing flow:
Check Redis for a key nonce:{nonce}. If it does not exist, create it with an expiration time equal to the timestamp window (e.g., 60 s).
If the key already exists, the request is a replay and should be rejected.
Combining nonce and timestamp in the signature ensures that a request is valid only once within the time window, effectively preventing replay attacks.
Code Implementation
Below is a complete Spring Boot implementation of anti‑tampering and anti‑replay protection.
1. Request Header Object
@Data
@Builder
public class RequestHeader {
private String sign;
private Long timestamp;
private String nonce;
}2. Utility Class to Extract Parameters
@Slf4j
@UtilityClass
public class HttpDataUtil {
/**
* POST request: get 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);
}
/**
* 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;
}
}3. Signature Verification Utility
@Slf4j
@UtilityClass
public class SignUtil {
/**
* Verify signature using timestamp, nonce 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);
}
/**
* Verify signature directly from a string
*/
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);
}
}4. HttpServletRequest Wrapper
public class SignRequestWrapper extends HttpServletRequestWrapper {
// Save the request body stream
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()));
}
}5. Filter Configuration
@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();
}
}6. Security Filter
@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);
}
}7. Redis Utility
@Component
public class RedisUtil {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* Check if a key exists
*/
public boolean hasKey(String key) {
try {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
/**
* Set a value with expiration time (seconds)
*/
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;
}
}
/**
* Set a value without expiration
*/
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Su San Talks Tech
Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.
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.
