Webhook Security: HMAC-SHA256 Signatures and Replay Protection
Fast recall
Quick revision
Core answer
timestamp + "." + raw_body using a shared secret, send the result in an X-Signature header along with the timestamp, and have the receiver recompute and constant-time compare. To prevent replay attacks, include the signing timestamp in the signature and have the receiver reject anything older than a tolerance window (Stripe uses 5 minutes). HMAC verification + a tight tolerance window is the standard pattern used by Stripe, GitHub, Slack, and Shopify; in Spring Boot it's a small OncePerRequestFilter that wraps the request to read the raw body without consuming it.Whether you understand why webhooks need authentication, how HMAC works, why the timestamp matters, and how to verify signatures correctly in Java
Using `String.equals()` to compare signatures (timing-attack vulnerable) or computing HMAC over the parsed JSON instead of the raw bytes the sender signed
Discuss replay protection via timestamp tolerance, secret rotation by sending two signatures, and reading the raw body in Spring without breaking `@RequestBody` deserialization
Interview-ready guide
Interview answer
The webhook URL is public, so the receiver needs to prove the sender is real. TLS authenticates the endpoint but not the request, so we use HMAC over the body with a shared secret.
Signing:
- The sender computes
HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encodes it, and sends both the signature and the timestamp as headers — Stripe-styleStripe-Signature: t=...,v1=... - Including the timestamp in the signed payload is what prevents replay attacks — the same body sent later produces a different signature
Verification on the receiver:
- Parse the timestamp and reject anything outside a tolerance window — Stripe uses 5 minutes, which balances clock-drift tolerance against replay window
- Recompute the HMAC using the raw request bytes, not the parsed JSON — different serializers produce different bytes for equivalent objects, so signing the parsed body is the most common bug
- Compare with
MessageDigest.isEqualfor constant-time comparison —String.equalsandArrays.equalsleak timing information that lets an attacker guess one byte at a time
In Spring Boot:
- Implement as a
OncePerRequestFilterthat usesContentCachingRequestWrapperso the controller can still bind@RequestBodyafter the filter consumed the body for verification - Short-circuit with 401 on any failure — missing headers, expired timestamp, or signature mismatch
- Don't include any detail in the error response so probing attackers learn nothing
Operational concerns:
- Secret in a secrets manager or Kubernetes Secret, never in source
- Multi-tenant systems give each tenant their own secret so a leak compromises only one customer
- Rotation by accepting two signatures during the rotation window — the receiver tries the new key first and falls back to the old
- For full replay protection, pair the timestamp window with an event-ID dedupe cache (Redis, 10-minute TTL) so the same event ID can't be replayed within the tolerance window
My production default is HMAC-SHA256 with 5-minute tolerance, raw-byte verification via ContentCachingRequestWrapper, constant-time compare, and Redis-backed event-ID dedupe.
Webhook Security: HMAC-SHA256 Signatures and Replay Protection
Direct Answer
Sign every outbound webhook with HMAC-SHA256 over timestamp + "." + raw_body using a shared secret, send the result in an X-Signature header along with the timestamp, and have the receiver recompute and constant-time compare. To prevent replay attacks, include the signing timestamp in the signature and have the receiver reject anything older than a tolerance window (Stripe uses 5 minutes). HMAC verification + a tight tolerance window is the standard pattern used by Stripe, GitHub, Slack, and Shopify; in Spring Boot it's a small OncePerRequestFilter that wraps the request to read the raw body without consuming it.
Code Example
@Component
public class WebhookSignatureFilter extends OncePerRequestFilter {
private static final String HEADER_TIMESTAMP = "X-Timestamp";
private static final String HEADER_SIGNATURE = "X-Signature";
private static final Duration TOLERANCE = Duration.ofMinutes(5);
private static final String ALGO = "HmacSHA256";
private final SecretKeySpec currentKey;
private final SecretKeySpec previousKey; // null unless mid-rotation
public WebhookSignatureFilter(@Value("${webhook.secret.current}") String current,
@Value("${webhook.secret.previous:}") String previous) {
this.currentKey = new SecretKeySpec(current.getBytes(StandardCharsets.UTF_8), ALGO);
this.previousKey = previous.isBlank() ? null
: new SecretKeySpec(previous.getBytes(StandardCharsets.UTF_8), ALGO);
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
if (!req.getRequestURI().startsWith("/webhooks/")) {
chain.doFilter(req, res);
return;
}
String ts = req.getHeader(HEADER_TIMESTAMP);
String sig = req.getHeader(HEADER_SIGNATURE);
if (ts == null || sig == null) { res.sendError(401, "missing signature"); return; }
long sentAt;
try { sentAt = Long.parseLong(ts); }
catch (NumberFormatException e) { res.sendError(401, "bad timestamp"); return; }
if (Math.abs(Instant.now().getEpochSecond() - sentAt) > TOLERANCE.getSeconds()) {
res.sendError(401, "timestamp outside tolerance window");
return;
}
// Wrap so downstream @RequestBody can still read the body.
ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(req);
// Force the wrapper to consume the input stream so getContentAsByteArray() is populated.
StreamUtils.copy(wrapped.getInputStream(), OutputStream.nullOutputStream());
byte[] raw = wrapped.getContentAsByteArray();
byte[] signed = (ts + "." + new String(raw, StandardCharsets.UTF_8))
.getBytes(StandardCharsets.UTF_8);
if (!verify(signed, sig, currentKey) &&
(previousKey == null || !verify(signed, sig, previousKey))) {
res.sendError(401, "signature mismatch");
return;
}
chain.doFilter(wrapped, res);
}
private boolean verify(byte[] signedPayload, String hexSignature, SecretKeySpec key) {
try {
Mac mac = Mac.getInstance(ALGO);
mac.init(key);
byte[] expected = mac.doFinal(signedPayload);
byte[] given = HexFormat.of().parseHex(hexSignature);
return MessageDigest.isEqual(expected, given); // constant-time
} catch (GeneralSecurityException | IllegalArgumentException e) {
return false;
}
}
}
@RestController
@RequestMapping("/webhooks")
public class StripeWebhookController {
private final OrderService orders;
@PostMapping("/stripe")
public ResponseEntity<Void> handle(@RequestBody StripeEvent event) {
// Filter already verified the signature; safe to process.
orders.applyStripeEvent(event);
return ResponseEntity.ok().build();
}
}
Critical points in this implementation:
MessageDigest.isEqualis the constant-time comparison; never useArrays.equalsfor a security-sensitive byte compare and never useString.equalsfor the hex strings.- The signed message includes the timestamp so the same body sent later produces a different signature — that's what protects against replay.
- During rotation we accept either the current or the previous secret; once all senders are on the new key, drop the previous from configuration.
ContentCachingRequestWrapperlets the controller still bind@RequestBody; without this, Jackson would find an empty input stream because the filter consumed it.- The filter is
OncePerRequestFilterand short-circuits on any failure — the controller is never invoked for an unverified request.
Common Mistakes & Pitfalls
Using String.equals() to compare signatures (timing-attack vulnerable) or computing HMAC over the parsed JSON instead of the raw bytes the sender signed
Check your understanding
Answer these without looking back at the article.
- Why must HMAC be computed over the raw request bytes instead of the parsed JSON?
- What's the difference between `MessageDigest.isEqual` and `Arrays.equals` for signature comparison?
- How would you rotate a webhook signing secret without downtime?
- What additional protection does an event-ID cache provide on top of the timestamp tolerance window?
Related interview prep categories
2Want a guided curriculum order?
These modules are also sequenced in our flagship backend interview track — open it when you prefer an ordered path instead of browsing by topic.