How to Secure APIs: Prevent Tampering and Replay Attacks with Spring Boot
This article explains why publicly exposed APIs are vulnerable to tampering and replay attacks, outlines anti‑tampering and anti‑replay strategies such as HTTPS, request signing, timestamp and nonce mechanisms, and provides a complete Spring Boot implementation with Redis support.
Anti‑Tampering
Public APIs are exposed to the internet, making them prone to tampering; an attacker who knows the endpoint and parameters can manipulate requests, e.g., altering a recharge API to add arbitrary balance.
Typical solutions include using HTTPS for encryption, but HTTPS alone cannot prevent replay or tampering if the attacker can capture and reuse the request. Two practical approaches are:
Encrypt request data via HTTPS, making decryption costly for attackers.
Validate request parameters on the server side to detect modifications.
Implementation Steps
Client encrypts parameters with a shared secret key, generates a signature
sign1, and includes it in the request.
Server receives the request, recomputes the signature
sign2using the same secret, and compares
sign1and
sign2. A mismatch indicates tampering.
Anti‑Replay
Replay attacks reuse a captured request without modification, causing duplicate data or overwhelming slow‑query endpoints.
Two common defenses are:
Timestamp‑based validation: add a
timestampparameter, sign it together with other parameters, and reject requests older than a configured window (e.g., 60 seconds).
Nonce‑plus‑timestamp: generate a unique random string (
nonce) for each request, combine it with the timestamp, and store the nonce in Redis. If the nonce already exists within the window, the request is considered a replay.
Timestamp Solution
Each HTTP request includes a
timestampparameter. The server checks the difference between the current time and the timestamp; if it exceeds the allowed interval, the request is rejected.
Nonce + Timestamp Solution
The client creates a
nonce(a one‑time random string) combined with the timestamp and signs them. The server checks Redis for the key
nonce:{nonce}:
If the key does not exist, create it with the same expiration as the timestamp window (e.g., 60 s).
If the key exists, the request is a replay and is rejected.
Code Implementation
The following Java code demonstrates how to implement anti‑tampering and anti‑replay in a Spring Boot application.
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 {
/**
* Extract POST 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);
}
/**
* Extract GET URL parameters and convert 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
*/
@SneakyThrows
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 and Implementation
<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;
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()) || requestHeader.getTimestamp() == null || 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>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.