Intermediate · Harden
Receiving Webhooks Safely
Accept provider callbacks with verified signatures, timestamp windows, and idempotent handling so replays cannot corrupt state.
Primary source: Spring Boot 3.5 Reference: Calling REST Services with RestClient
Lesson 2 of 3
1d streakThe Architectural Question
Webhook endpoints are public unauthenticated POST targets; accepting JSON without verifying who sent it lets anyone mark orders paid.
What this guide proves
- Verify HMAC signatures over raw request bytes
- Reject stale timestamps to block replay attacks
- Process events idempotently with stored event ids to handle at-least-once delivery
Inbound Webhook Verification: HMAC-SHA256 & Timing Attack Defense
Public webhook endpoints (e.g. from Stripe, GitHub, or Shopify) receive events over the public internet. Without cryptographic signature verification, malicious actors can forge payloads and trigger fraudulent state transitions.
Providers calculate an HMAC-SHA256 hash using a shared secret key and send it in a header such as X-Signature-256. The receiver must recalculate the hash over the exact raw byte payload and verify equivalence.
Never Use String.equals() for Cryptographic Hashes
package com.example.webhook;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class HmacVerifier {
private HmacVerifier() {}
public static boolean verify(byte[] payload, String signatureHeader, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(secretKey);
byte[] computedHash = mac.doFinal(payload);
String computedHex = HexFormat.of().formatHex(computedHash);
byte[] supplied = signatureHeader == null
? new byte[0]
: signatureHeader.getBytes(StandardCharsets.US_ASCII);
byte[] expected = computedHex.getBytes(StandardCharsets.US_ASCII);
if (supplied.length != expected.length) {
return false;
}
return MessageDigest.isEqual(expected, supplied);
} catch (Exception ex) {
return false;
}
}
}Resolving the Servlet Single-Read Body Dilemma
A standard servlet request body is a one-shot stream. If a signature-verification filter consumes it, Spring MVC cannot deserialize the same stream unless the filter buffers the bytes and supplies a replayable request wrapper.
Read the body once, verify the HMAC over those exact bytes, then pass a wrapper whose getInputStream() and getReader() each create a fresh view over the cached bytes. Bound the accepted body size in production so buffering cannot become a memory-exhaustion risk.
package com.example.webhook;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
final class CachedBodyRequest extends HttpServletRequestWrapper {
private final byte[] body;
CachedBodyRequest(HttpServletRequest request, byte[] body) {
super(request);
this.body = body.clone();
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream input = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override public int read() { return input.read(); }
@Override public boolean isFinished() { return input.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(ReadListener listener) { }
};
}
@Override
public BufferedReader getReader() throws IOException {
String encoding = getCharacterEncoding();
return new BufferedReader(new InputStreamReader(
getInputStream(), encoding == null ? StandardCharsets.UTF_8 : encoding));
}
}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class WebhookVerificationFilter extends OncePerRequestFilter {
private final String webhookSecret;
private static final int MAX_BODY_BYTES = 1_000_000;
public WebhookVerificationFilter(@Value("${webhook.secret}") String webhookSecret) {
this.webhookSecret = webhookSecret;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!request.getRequestURI().startsWith("/api/webhooks/")) {
filterChain.doFilter(request, response);
return;
}
long contentLength = request.getContentLengthLong();
if (contentLength > MAX_BODY_BYTES) {
response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
return;
}
byte[] body = request.getInputStream().readNBytes(MAX_BODY_BYTES + 1);
if (body.length > MAX_BODY_BYTES) {
response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
return;
}
String signature = request.getHeader("X-Signature-256");
if (!HmacVerifier.verify(body, signature, webhookSecret)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(new CachedBodyRequest(request, body), response);
}
}Idempotent Ingestion & At-Least-Once Delivery Guarantees
Webhook providers commonly use at-least-once delivery. Network retries, timeout reconnections, and gateway glitches mean your endpoint should assume duplicate webhook events can arrive for the same business action.
To prevent duplicate orders or double-refunding customers, persist an idempotent ledger of processed event IDs in PostgreSQL protected by a unique database constraint.
package com.example.webhook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class WebhookIngestionService {
private final ProcessedEventRepository repository;
private final OrderFulfillmentService fulfillmentService;
public WebhookIngestionService(ProcessedEventRepository repository, OrderFulfillmentService fulfillmentService) {
this.repository = repository;
this.fulfillmentService = fulfillmentService;
}
@Transactional
public void processEvent(String eventId, WebhookPayload payload) {
// Attempt to insert event record with UNIQUE constraint on event_id
try {
repository.saveAndFlush(new ProcessedEvent(eventId, payload.eventType()));
} catch (DataIntegrityViolationException ex) {
// Event already processed in a prior delivery; acknowledge with the provider's success status
return;
}
// The database transaction and unique key protect the ingestion decision;
// make downstream side effects idempotent or use an outbox for reliable publication.
fulfillmentService.fulfill(payload.orderId());
}
}Testing Webhook Signature Verification and Replays
Validate signature checks and replay defense using curl commands with precomputed signatures:
# 1. Send request with valid HMAC signature
curl -i -X POST http://localhost:8080/api/webhooks/stripe \
-H "Content-Type: application/json" \
-H "X-Signature-256: 6a2c7104b...valid_hex..." \
-d '{"eventId":"evt_1001","eventType":"order.paid","orderId":"ORD-42"}'
# HTTP/1.1 200 OK
# 2. Send same request again (replay)
curl -i -X POST http://localhost:8080/api/webhooks/stripe \
-H "Content-Type: application/json" \
-H "X-Signature-256: 6a2c7104b...valid_hex..." \
-d '{"eventId":"evt_1001","eventType":"order.paid","orderId":"ORD-42"}'
# HTTP/1.1 200 OK (deduplicated, order not fulfilled twice!)
# 3. Send tampered payload with invalid signature
curl -i -X POST http://localhost:8080/api/webhooks/stripe \
-H "Content-Type: application/json" \
-H "X-Signature-256: invalid_signature" \
-d '{"eventId":"evt_1002","eventType":"order.paid","orderId":"ORD-42"}'
# HTTP/1.1 401 UnauthorizedActive Recall Checkpoint
Why should a duplicate webhook delivery return the provider’s documented success status instead of an application-level conflict?
Hint: Consider how the webhook sender distinguishes accepted delivery from failed delivery.
Show answer & explanation
Many webhook providers retry any non-2xx response. After authenticating and recording the event idempotently, return the provider’s documented successful 2xx response so a duplicate is acknowledged without repeating the side effect; do not assume every provider requires exactly 200.
Runtime Architecture & Flow
Spring MVC · runtime visualization
Receiving Webhooks Safely
Dispatcher
Verify HMAC signatures over raw request bytes
Binding
Reject stale timestamps to block replay attacks
Use case
Process events idempotently with stored event ids to handle at-least-once delivery
Response
Receive a payment_succeeded webhook, verify its signature against the raw body, and prove duplicate deliveries apply once.
Working Implementation
Code Walkthrough
Receive a payment_succeeded webhook, verify its signature against the raw body, and prove duplicate deliveries apply once.
package com.example.webhook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class HmacWebhookIngestionLab {
private final Set<String> processedEventIds = ConcurrentHashMap.newKeySet();
public static boolean verifyHmacSha256(byte[] payload, String expectedSignatureHex, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(secretKey);
byte[] computedHash = mac.doFinal(payload);
String computedHex = HexFormat.of().formatHex(computedHash);
byte[] supplied = expectedSignatureHex == null
? new byte[0]
: expectedSignatureHex.getBytes(StandardCharsets.US_ASCII);
byte[] expected = computedHex.getBytes(StandardCharsets.US_ASCII);
if (supplied.length != expected.length) {
return false;
}
return MessageDigest.isEqual(expected, supplied);
} catch (Exception ex) {
return false;
}
}
public boolean ingestEventIdempotently(String eventId, String eventType) {
// Atomic insert: returns true if newly added, false if duplicate
return processedEventIds.add(eventId);
}
}Synthesis & Production Verification
Production Acceptance Criteria
- Reject requests with missing or invalid signatures using 400
- Reject timestamps older than the tolerance window
- Deliver one event twice and observe a single applied effect
Key Takeaway
Inbound webhooks require constant-time HMAC-SHA256 signature verification, ContentCachingRequestWrapper to resolve the single-read body constraint, and idempotent deduplication.
Architectural & Technical Review
Core Interview Questions
Q01Why must HMAC-SHA256 webhook signatures be validated using MessageDigest.isEqual() instead of String.equals()?Show answer
String.equals() returns false at the first non-matching byte, leaking timing information that attackers can exploit to forge signatures byte-by-byte. MessageDigest.isEqual() executes in constant time.
Q02How does ContentCachingRequestWrapper resolve the Servlet single-read body limitation for signature verification filters?Show answer
HttpServletRequest input stream can only be read once. Wrapping the request in ContentCachingRequestWrapper caches the raw payload bytes, allowing the filter to compute the HMAC hash while letting Jackson bind the request body later in the controller.
Q03How does an idempotent event table protect against duplicate webhook deliveries under at-least-once transport?Show answer
Webhook providers retry deliveries on timeout. Persisting the external event ID with a database UNIQUE constraint guarantees that duplicate deliveries are recognized and acknowledged with HTTP 200 without executing side effects twice.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Returning HTTP 400 or 500 when receiving a duplicate webhook delivery — Causes the provider to treat delivery as failed, triggering endless aggressive retry loops.
- Pitfall: Verifying signatures against pretty-printed or re-serialized JSON rather than the exact raw byte array — Fails signature verification because whitespace, key ordering, and character encodings differ.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources