-
Notifications
You must be signed in to change notification settings - Fork 7
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.
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 JSON — JSON.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.
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 whennow - 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.
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.
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 ...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.
- ✅ 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.
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.
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.
- 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).
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.
-
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)
Pages
Repository
SDK suite