Why Webhook Security Matters

Webhook Security Best Practices

Webhooks are HTTP callbacks that allow services to push real-time events to your application. Unlike APIs where you initiate the request, webhooks are delivered to a public endpoint that anyone with the URL can call. Without proper security, an attacker can replay webhook events, send fake event data, or probe your internal infrastructure.

Threat Model

| Threat | Impact | Likelihood | |--------|--------|------------| | Fake webhook events | Application processes false data | High | | Replay attacks | Duplicate processing of legitimate events | Medium | | Payload tampering | Corrupted data processed as valid | Medium | | Reconnaissance | Attacker probes internal network | Low | | DDoS via webhooks | Service overload from fake events | Medium |

Defense 1: Signature Verification

Every webhook provider signs their payloads. Your endpoint must verify this signature before processing.

Stripe-Style Signatures

import hmac

import hashlib

def verify_stripe_signature(payload, sig_header, secret):

"""Verify Stripe webhook signature."""

Signature format: t=timestamp,v1=signature

parts = dict(item.split('=', 1) for item in sig_header.split(','))

if 'v1' not in parts or 't' not in parts:

return False

timestamp = parts['t']

expected_sig = parts['v1']

Prevent replay: signature must be within 5 minutes

if abs(int(timestamp) - time.time()) > 300:

return False

Compute expected signature

signed_payload = f"{timestamp}.{payload}".encode()

computed_sig = hmac.new(

secret.encode(),

signed_payload,

hashlib.sha256

).hexdigest()

Constant-time comparison

return hmac.compare_digest(computed_sig, expected_sig)

GitHub-Style Signatures

const crypto = require('crypto');

function verifyGitHubSignature(req, secret) {

const signature = req.headers['x-hub-signature-256'];

if (!signature) return false;

const payload = JSON.stringify(req.body);

const computed = 'sha256=' +

crypto.createHmac('sha256', secret)

.update(payload)

.digest('hex');

// Constant-time comparison

return crypto.timingSafeEqual(

Buffer.from(signature),

Buffer.from(computed)

);

}

// Express middleware

app.post('/webhooks/github', express.raw({type: 'application/json'}), (req, res) => {

if (!verifyGitHubSignature(req, process.env.GITHUB_WEBHOOK_SECRET)) {

return res.status(401).send('Invalid signature');

}

// Process webhook

res.status(200).send('OK');

});

Generic Verification Middleware

from functools import wraps

from flask import request, abort

import hmac

import hashlib

def verify_webhook(secret):

"""Decorator to verify webhook signatures."""

def decorator(f):

@wraps(f)

def decorated_function(args, *kwargs):

payload = request.get_data()

received_sig = request.headers.get('X-Webhook-Signature')

if not received_sig:

abort(401, 'Missing signature')

computed_sig = hmac.new(

secret.encode(),

payload,

hashlib.sha256

).hexdigest()

if not hmac.compare_digest(computed_sig, received_sig):

abort(401, 'Invalid signature')

return f(args, *kwargs)

return decorated_function

return decorator

@app.route('/webhooks/custom', methods=['POST'])

@verify_webhook(WEBHOOK_SECRET)

def handle_webhook():

event = request.json

Process the verified event

return 'OK', 200

Defense 2: Replay Protection

Timestamp-Based Prevention

Include a timestamp in the signed payload and reject webhooks outside a time window:

function verifyWithReplayPrevention(payload, signature, secret, toleranceMs = 300000) {

// Expected format: ts=1234567890,v1=sig_here

const parts = signature.split(',');

const tsPart = parts.find(p => p.startsWith('ts='));

const sigPart = parts.find(p => p.startsWith('v1='));

const timestamp = parseInt(tsPart.split('=')[1]);

const now = Date.now();

// Reject if outside tolerance window

if (Math.abs(now - timestamp) > toleranceMs) {

throw new Error('Webhook replay detected');

}

// Verify signature includes timestamp

const signedContent = ${timestamp}.${JSON.stringify(payload)};

const expectedSig = crypto.createHmac('sha256', secret)

.update(signedContent)

.digest('hex');

return crypto.timingSafeEqual(

Buffer.from(expectedSig),

Buffer.from(sigPart.split('=')[1])

);

}

Idempotency Keys

Track processed webhook IDs to prevent duplicate processing:

const processedEvents = new Set();

app.post('/webhooks/stripe', async (req, res) => {

const eventId = req.headers['stripe-signature']

.split(',')

.find(p => p.startsWith('t='))

?.split('=')[1];

if (processedEvents.has(eventId)) {

// Already processed, return success to avoid retries

return res.status(200).json({ status: 'already_processed' });

}

// Verify and process

processedEvents.add(eventId);

// Clean old entries periodically

setTimeout(() => processedEvents.delete(eventId), 86400000);

});

Defense 3: IP Allowlisting

When possible, restrict webhook endpoints to known provider IPs:

ALLOWED_IPS = {

'stripe': ['3.18.12.63/32', '3.130.192.231/32'],

'github': ['192.30.252.0/22', '185.199.108.0/22'],

'slack': ['52.89.214.238/32', '54.70.199.8/32'],

}

def ip_allowed(provider, request_ip):

import ipaddress

for cidr in ALLOWED_IPS.get(provider, []):

if ipaddress.ip_address(request_ip) in ipaddress.ip_network(cidr):

return True

return False

@app.before_request

def restrict_webhook_ips():

if request.path.startswith('/webhooks/'):

provider = request.path.split('/')[2]

if not ip_allowed(provider, request.remote_addr):

abort(403, 'IP not allowed')

Defense 4: Payload Validation

Validate the webhook payload schema before processing:

from pydantic import BaseModel, ValidationError

class GitHubPushEvent(BaseModel):

ref: str

repository: dict

commits: list

sender: dict

forced: bool = False

def validate_webhook_payload(provider, payload):

validators = {

'github': GitHubPushEvent,

'stripe': StripeEvent,

'slack': SlackEvent

}

validator = validators.get(provider)

if not validator:

return False

try:

validator(**payload)

return True

except ValidationError:

return False

Implementation Checklist

| Security Control | Implementation | Priority | |-----------------|----------------|----------| | HMAC signature verification | Verify every webhook | Critical | | Replay protection | Timestamp + tolerance | Critical | | Idempotency | Track event IDs | High | | IP allowlisting | Restrict by provider CIDR | High | | Payload validation | Schema validation | High | | Rate limiting | Per-IP and per-provider | Medium | | HTTPS only | Reject HTTP | Critical | | Error handling | Generic error messages | Medium |

Summary

Webhook endpoints are publicly accessible by design, making them an attractive target. Always verify payload signatures using HMAC-SHA256, implement replay protection with timestamps, track idempotency keys to prevent duplicate processing, validate payload structure, and restrict by IP when possible. These controls turn a publicly accessible endpoint into a secure integration point that only processes legitimate webhook events.