Spring Boot cookbook · Intermediate

Verify webhook signatures in Spring Boot

Accept signed webhooks from a provider, reject forgeries and replays, and process each event once.

Approach

Receive the raw body as a String/byte[], compute an HMAC-SHA256 over timestamp.body, compare in constant time with MessageDigest.isEqual, check the timestamp tolerance, then insert the event ID with a unique constraint and process asynchronously.

Solution

@RestController
class PaymentWebhookController {
    private final byte[] secret;
    private final WebhookEventRepository events;
    private final ObjectMapper json;

    PaymentWebhookController(@Value("${payments.webhook-secret}") String secret, WebhookEventRepository events, ObjectMapper json) {
        this.secret = secret.getBytes(StandardCharsets.UTF_8); this.events = events; this.json = json;
    }

    @PostMapping(path = "/webhooks/payments", consumes = MediaType.APPLICATION_JSON_VALUE)
    ResponseEntity<Void> receive(@RequestHeader("X-Signature") String signature, @RequestBody String body) throws Exception {
        Map<String, String> parts = Arrays.stream(signature.split(","))
                .map(p -> p.split("=", 2)).filter(a -> a.length == 2)
                .collect(Collectors.toMap(a -> a[0], a -> a[1]));
        long ts = Long.parseLong(parts.getOrDefault("t", "0"));
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        byte[] expected = HexFormat.of().formatHex(mac.doFinal((ts + "." + body).getBytes(StandardCharsets.UTF_8)))
                .getBytes(StandardCharsets.UTF_8);
        byte[] given = parts.getOrDefault("v1", "").getBytes(StandardCharsets.UTF_8);
        if (Math.abs(Instant.now().getEpochSecond() - ts) > 300 || !MessageDigest.isEqual(expected, given)) {
            return ResponseEntity.badRequest().build();
        }
        JsonNode event = json.readTree(body);
        events.insertIfAbsent(event.get("id").asText(), event.get("type").asText(), body);   // ON CONFLICT DO NOTHING
        return ResponseEntity.ok().build();
    }
}

Pitfalls

  • Binding the body to a DTO first and verifying re-serialized JSON.
  • String.equals for signature comparison.
  • Doing slow work before responding.

Variations

  • Use the provider's Java SDK verifier (e.g., Stripe Webhook.constructEvent).

Further reading: Stripe: Verify webhook events

Reference: https://docs.stripe.com/webhooks#verify-events

More Spring Boot recipes