Webhook Security Best Practices
Signature verification for Stripe, GitHub, and Shopify. HMAC-SHA256, replay attacks, IP allowlisting, HTTPS, and secret rotation.
HookDeploy Team · May 22, 2026
Webhook endpoints are public URLs. Anyone who discovers yours can POST fake events. This guide covers how to verify that webhooks actually came from the provider you expect.
Always verify signatures
The single most important security step: verify the cryptographic signature on every incoming webhook before processing it.
Providers sign payloads with a shared secret. The signature proves:
- The payload was not modified in transit
- The sender knows the secret (i.e., it came from the provider or someone with access)
Never skip verification because “it works in dev.” One unverified endpoint in production is an open door.
HMAC-SHA256 explained
Most providers (Stripe, GitHub, Shopify, Clerk) use HMAC-SHA256:
- Provider computes:
HMAC-SHA256(secret, raw_request_body) - Provider sends the hash in a header (often prefixed with
sha256=) - Your server computes the same HMAC with your copy of the secret
- Compare using a timing-safe comparison function
const crypto = require('crypto');
function verifyHmac(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Strip prefix if present (e.g., "sha256=")
const received = signature.replace(/^sha256=/, '');
return crypto.timingSafeEqual(
Buffer.from(received, 'hex'),
Buffer.from(expected, 'hex')
);
}
Provider-specific verification
Stripe
Header: Stripe-Signature (contains timestamp + signatures)
const event = stripe.webhooks.constructEvent(rawBody, sig, whsec_secret);
Stripe also validates timestamp freshness (rejects events older than 5 minutes) to prevent replay attacks.
GitHub
Header: X-Hub-Signature-256
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
Also check X-GitHub-Event matches the event type you expect to handle.
Shopify
Header: X-Shopify-Hmac-Sha256 (base64-encoded HMAC)
const hash = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(hash));
Shopify sends X-Shopify-Topic for the event type and X-Shopify-Shop-Domain for the shop.
Replay attacks
A replay attack re-sends a previously captured valid webhook. Defenses:
Timestamp validation — Stripe embeds a timestamp in the signature and rejects old events. Implement similar checks for other providers:
const MAX_AGE_SECONDS = 300; // 5 minutes
const timestamp = parseInt(sigParts.t, 10);
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) {
throw new Error('Timestamp too old');
}
Idempotency keys — track processed event IDs and reject duplicates:
const eventId = event.id; // Stripe evt_xxx, GitHub X-GitHub-Delivery
if (await db.processedEvents.exists(eventId)) {
return response.status(200).send('Already processed');
}
await db.processedEvents.insert(eventId);
HookDeploy replay header — when replaying from HookDeploy, check for x-hookdeploy-replay: 1 and skip side effects if needed.
IP allowlisting
Some providers publish IP ranges for webhook senders. Allowlisting adds defense-in-depth but is not sufficient alone — IPs can change and spoofing is possible at the TCP level without TLS client certs.
| Provider | IP list available |
|---|---|
| Stripe | stripe.com/docs/ips |
| GitHub | api.github.com/meta |
| Shopify | shopify.dev/docs/apps/build/webhooks/subscribe/https |
Use IP allowlisting as a supplement to signature verification, not a replacement.
HTTPS requirements
Every webhook endpoint must use HTTPS. Providers reject HTTP URLs in production configurations.
HookDeploy endpoints are always HTTPS: https://hookdeploy.dev/h/{slug}. When forwarding or replaying to your server, the target URL must also be HTTPS.
For local development, use ngrok or Cloudflare Tunnel — both provide HTTPS URLs that tunnel to localhost.
Secret rotation
Rotate webhook secrets periodically and after any suspected compromise:
- Generate a new secret in the provider dashboard
- Update your application’s environment variable
- Deploy the new secret
- Revoke the old secret in the provider dashboard
During rotation, some providers support two active secrets simultaneously. Check your provider’s docs.
HookDeploy-specific security notes
- Endpoint slugs are not secret — treat them like public URLs
- Anyone who knows your slug can POST to it — your handler must verify signatures
- HookDeploy stores payloads encrypted at rest — but limit sensitive data in test webhooks
- Use role-based access in HookDeploy so only your team sees captured payloads
- API keys are hashed — rotate if exposed
Checklist
- Signature verification on every webhook route
- Raw body preserved for HMAC computation
- Timing-safe comparison (not
===) - Timestamp freshness check where supported
- Idempotency on event/delivery ID
- HTTPS only
- Secrets in environment variables, not code
- Secret rotation plan documented
Related guides
Related guides
- Testing Stripe webhooks with HookDeploy
Complete guide to capturing, inspecting, and debugging Stripe webhook events using HookDeploy, the Stripe CLI, and signature verification.
- Testing Shopify webhooks with HookDeploy
Shopify Admin API webhooks, HMAC verification, mandatory webhooks, rate limits, and GDPR compliance webhooks.
- Webhook debugging guide
Systematic approach to debugging webhook issues — delivery logs, signatures, payloads, curl testing, and common HTTP error codes.