Webhook Security: How to Validate and Secure Incoming Webhooks
Webhooks are how the modern web communicates — GitHub notifies your CI pipeline, Stripe confirms a payment, and your monitoring tool fires an alert when a threshold is breached. But webhooks are also a common attack vector: an unverified webhook endpoint will happily process a forged request from anyone who discovers the URL.
This guide covers everything you need to secure your webhook endpoints.
The Core Problem
Your webhook endpoint is a public URL. Without verification, any attacker who knows (or guesses) the URL can send fake events — triggering deployments, updating records, or causing other unintended actions.
The solution is signature verification: the sender signs each request with a shared secret, and you verify the signature before processing the payload.
How Webhook Signatures Work
- 1You generate a shared secret when registering the webhook with the provider
- 2The provider computes an HMAC of the request body using that secret
- 3The provider sends the HMAC in a request header (e.g.,
X-Hub-Signature-256) - 4You recompute the HMAC on your end and compare — if they match, the payload is authentic
Step 1: Verify GitHub Webhook Signatures
const crypto = require('crypto');
function verifyGitHubSignature(req, secret) {
const signature = req.headers['x-hub-signature-256'];
if (!signature) return false;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(req.rawBody); // must be the raw, unparsed body
const digest = 'sha256=' + hmac.digest('hex');
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
}
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
req.rawBody = req.body;
if (!verifyGitHubSignature(req, process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body.toString());
// process payload safely
res.status(200).json({ received: true });
});Critical: Use express.raw() (not express.json()) for webhook routes to preserve the raw body for signature verification. Parsing JSON first modifies the body and breaks HMAC comparison.
Step 2: Always Use timingSafeEqual
Never compare signatures with === or ==. String comparison in most languages short-circuits on the first mismatch, which leaks information about how close an attacker's guess is (a timing attack).
// Wrong — vulnerable to timing attacks
if (signature === digest) { ... }
// Right — constant-time comparison
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))Step 3: Prevent Replay Attacks
A valid but old webhook payload, replayed by an attacker, can cause duplicate actions. Prevent this with timestamps:
function isReplayAttack(req) {
const timestamp = req.headers['x-webhook-timestamp'];
if (!timestamp) return true;
const eventTime = parseInt(timestamp, 10) * 1000;
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
return Math.abs(now - eventTime) > fiveMinutes;
}
app.post('/webhooks/stripe', (req, res) => {
if (isReplayAttack(req)) {
return res.status(400).json({ error: 'Stale request rejected' });
}
// continue processing
});Step 4: Use Idempotency Keys
Even with replay protection, network retries can cause duplicate deliveries. Use the event ID to deduplicate:
async function processWebhook(eventId, payload) {
const alreadyProcessed = await WebhookEvent.findOne({ where: { eventId } });
if (alreadyProcessed) return { status: 'already_processed' };
await WebhookEvent.create({ eventId, processedAt: new Date() });
// process payload
}Step 5: Respond Quickly, Process Asynchronously
Webhook providers expect a fast response (usually within 5-30 seconds). For any processing that might take longer, acknowledge immediately and process in the background:
app.post('/webhooks/github', verifySignatureMiddleware, async (req, res) => {
// Acknowledge immediately
res.status(200).json({ received: true });
// Process asynchronously
await queue.publish('webhook-events', req.body);
});PandaStack's GitHub integration uses this pattern — deployment webhooks are acknowledged instantly and queued for processing.
Step 6: Use HTTPS and Restrict IPs
Your webhook endpoint must use HTTPS. Additionally, many providers publish their IP ranges — allowlisting those ranges adds a network-level layer of defense.
# Example: GitHub's IP ranges
curl https://api.github.com/meta | jq '.hooks'Step 7: Log All Webhook Events
Log every received event with its ID, source, verification result, and processing outcome. This is invaluable for debugging and incident investigation.
logger.info({
eventId: payload.id,
source: 'github',
event: req.headers['x-github-event'],
verified: true,
timestamp: new Date().toISOString(),
});Webhooks and PandaStack
PandaStack's GitHub integration uses webhook signatures to securely receive push and PR events, triggering builds and deployments. Set up GitHub integration and webhook-based deployments via:
npm install -g @pandastack/cli
panda integration:connect githubConfigure everything at [dashboard.pandastack.io](https://dashboard.pandastack.io). Full documentation at [docs.pandastack.io](https://docs.pandastack.io).
Summary
Secure webhooks require: signature verification using HMAC, constant-time comparison to prevent timing attacks, timestamp validation to prevent replay attacks, idempotency keys to handle duplicate delivery, fast acknowledgment with async processing, HTTPS, and comprehensive logging.