Signature verification on webhooks gets implemented carefully, because there is a library for it. The two things the library does not do for you are the ones that broke here.
What was checked, and what was not
The handler verified the HMAC over the raw body. It did not look at the t= component of the signature header, which carries the timestamp the signature covers. So a captured callback stayed valid forever.
for i in $(seq 1 20); do curl -s -X POST https://api.target.tld/webhooks/payments \ -H "X-Signature: t=1746███████,v1=████████████████" \ -H "Content-Type: application/json" \ --data-binary @captured.json &done; waitThe idempotency key in the payload was checked, but with a findOne before the balance update rather than a unique constraint inside the transaction. Twenty parallel requests all read “not seen yet”, and all twenty credited the balance.
HTTP/2 200 OKcontent-type: application/json{"balance": 20 × ████, "currency":"EUR"}Takeaways
- Verifying a signature proves authorship, not freshness. Compare the signed timestamp to your clock and reject anything outside a few minutes.
- An idempotency key is only idempotent if the database enforces it. A
findOnecheck is a read, and reads race. - Webhook handlers deserve the same concurrency testing as checkout flows. They move money with no user in front of them.
Disclosure timeline
- Report sent
- Triaged
- Fix deployed timestamp window added, idempotency moved into the transaction
- Cleared for publication