Skip to content

Webhook security checklist

Pim Feltkamp edited this page Apr 28, 2026 · 1 revision

Webhook security checklist

Production-grade webhook receivers have a half-dozen things to get right. Sample receivers in webhooks/ cover most of them; this page is the running checklist.

1. Verify the signature

If you've configured a webhook secret on your Cryptohopper app, the server signs every webhook body with HMAC-SHA256 and sends the digest in X-Cryptohopper-Signature. Re-compute the digest from the raw body and the shared secret, then compare with a constant-time function.

import crypto from "node:crypto";

const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
  return res.status(401).send("invalid signature");
}

Use the raw body, not the parsed JSONJSON.stringify re-ordering keys would break the digest. Express's verify callback (express.json({ verify: ... })) captures the raw bytes.

Never use === to compare digests — it's a timing attack. timingSafeEqual (Node), hmac.compare_digest (Python), subtle.ConstantTimeCompare (Go) are constant-time.

2. Reject old webhooks (replay protection)

If an attacker captures a webhook in transit and replays it five hours later, signature verification still passes. Defend with one of:

  • Timestamp tolerance — if Cryptohopper signs timestamp.body, reject when now - timestamp > 5min.
  • Event-ID dedupe — stash event IDs in a TTL'd cache (Redis is the right tool); reject already-seen IDs.

The Node sample's checklist flags both as "not implemented here" — implement them in production.

3. Acknowledge fast, work async

Cryptohopper retries slow webhooks. If your handler does heavy work (DB writes, calls to external APIs) inline, you'll:

  • Tip into the retry loop (the server thinks you didn't receive)
  • Block the receiver from accepting subsequent events
  • Eat your own rate-limit budget on retries

Right pattern: ACK with 200 immediately, push the work onto a queue (SQS, Cloud Tasks, BullMQ, RQ, Sidekiq, …), let workers pick it up.

4. Make handlers idempotent

Even with replay protection, retries happen. If your handler "increment a counter, send a notification" runs twice, the counter is wrong and the user gets two notifications. Idempotency-key by event ID:

# Pseudocode
event_id = payload["id"]
if events_seen.has(event_id): return 200  # duplicate, drop silently
events_seen.add(event_id, ttl=24*hours)
# ... do the work ...

5. Don't trust the source IP

Cryptohopper's egress IPs aren't a stable allowlist. The signature is the only authentication. IP filtering is fine as defense-in-depth but is not a substitute for timingSafeEqual on the digest.

6. Log enough to debug, not enough to leak

  • ✅ Log event type, event ID, timestamp, signature-verified-yes/no.
  • ❌ Don't log the raw body. It contains balances, order details, sometimes auth tokens.
  • ❌ Don't log the secret. Ever. Not even at debug level.

7. Rate-limit / DoS-protect the endpoint

If your webhook URL leaks, anyone can hammer it. The signature check stops them from succeeding, but they can still consume CPU. express-rate-limit / nginx limit_req_zone / Cloudflare rules at the edge — pick one.

8. Keep the secret rotateable

Cryptohopper's webhook secret is a per-app setting you can rotate. Your receiver should:

  • Read the secret from env / secrets manager, not hardcoded.
  • Be ready to verify against either the old or the new secret during the rotation window.

9. Test the failure paths

  • Wrong signature → 401, log, no side effects.
  • Malformed body → 400, log, no side effects.
  • Signature OK but DB write fails → return 500. Cryptohopper retries; your handler must be idempotent (see §4).

10. Don't block the response on slow side effects

Same as §3 but specifically: if your "send a Slack notification" call to Slack times out, your webhook handler shouldn't sit there for 30s. Use a short timeout, queue it, ACK fast.

See also

  • webhooks/nodejs/ — Express receiver with HMAC verify, raw-body capture, dispatch by event type
  • webhooks/php/ — older PHP receiver (lighter on hardening; a port to current best-practice is on the list)