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

30 min

Lesson 2 of 3

1d streak

The 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

Standard String.equals() can return after the first mismatch, which may expose timing differences. Use a vetted constant-time comparison such as MessageDigest.isEqual() for equal-length byte arrays, and validate the signature format and length before comparing.
src/main/java/com/example/webhook/HmacVerifier.java
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.

src/main/java/com/example/webhook/WebhookVerificationFilter.java
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.

src/main/java/com/example/webhook/WebhookIngestionService.java
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:

test-webhooks.sh
# 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 Unauthorized

Active 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.

HmacWebhookIngestionLab.java
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);
    }
}
Validate the signature format and length, then compare equal-length bytes with MessageDigest.isEqual() to reduce timing leakage.
A replayable request wrapper must expose fresh streams after buffering; ContentCachingRequestWrapper alone is not a replayable body when a filter consumes the request.
Webhook delivery is commonly at-least-once: acknowledge a deduplicated event with the provider’s documented success 2xx status, and make external side effects idempotent or publish them through an outbox.

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

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

Supplementary Module Resources