Webhooks are how money and state actually move on the internet. OTP top-ups,
eSIM provisioning, escrow transitions — none of them are real until the webhook
lands. Which means the webhook endpoint is an attack surface: a forged
ORDER_STATUS=paid is literally free money if you trust it blindly.
This is a field guide from three real systems — OTPku, JagoanESIM, and JagoanVirtual — framed as research. The goal is to make webhook handling boring and un-forgeable.
The contract: sign, then send
The provider computes HMAC-SHA256(secret, raw_body) and sends it in a header
(often x-signature or x-callback-token). You receive, recompute over the
raw request body, and compare. Two failure modes I've seen in the wild:
- Comparing the parsed body. JSON round-tripping changes byte order and breaks the signature. Always hash the raw bytes.
- Non-constant-time compare.
===short-circuits, leaking timing info that enables a signature oracle. Use a constant-time comparison.
import { timingSafeEqual, createHmac } from "node:crypto";
export function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
Replay protection
A captured valid webhook can be replayed. Mitigate with a timestamp window and an idempotency key:
- Reject if
|now - header_timestamp| > 5min— stale signatures die. - Dedupe by
(event_id, signature)in a short-lived store. The same logical event processed twice should credit a balance once, not twice.
Trust the signature, not the payload
Even after verifying, treat the body as untrusted input. Validate the shape with
zod, check that the referenced order actually exists and belongs to the right
tenant, and apply state-machine transitions (PENDING → PAID → DELIVERED)
rather than setting arbitrary status strings. Escrow especially: never move to
COMPLETED without confirming the prior state was DELIVERED.
Hardening checklist
- Verify signature over raw bytes, constant-time.
- Enforce a timestamp window + idempotency key.
- IP-allowlist the provider's callback ranges when possible.
- Never log full bodies or secrets; redact.
- Return
200fast and process async — a slow handler causes retries, which amplify your idempotency bugs.
The pattern is the same everywhere: sign, verify over raw bytes with constant-time compare, dedupe, validate, then transition state. Get those five right and webhooks stop being scary.