TL;DR
The #1 webhook security best practice is verifying signatures using HMAC with constant-time comparison. Process webhooks asynchronously and idempotently. Return 200 quickly and process in the background. Never trust webhook data without verification.
"An unverified webhook is an open door. Anyone can forge a request to your endpoint. Always verify the signature before trusting the payload."
Best Practice 1: Verify Webhook Signatures 5 min
Never process webhooks without verifying the signature:
import Stripe from 'stripe';
import { buffer } from 'micro';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(req) {
const body = await buffer(req);
const signature = req.headers.get('stripe-signature');
let event;
try {
// Stripe library handles signature verification
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return new Response('Invalid signature', { status: 400 });
}
// Process verified event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
}
return new Response('OK', { status: 200 });
}
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Use constant-time comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}
// Usage
app.post('/webhooks/provider', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook...
res.status(200).send('OK');
});
Best Practice 2: Process Asynchronously 4 min
Return quickly and process in the background:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks');
app.post('/webhooks/stripe', async (req, res) => {
// 1. Verify signature first
const event = verifyStripeSignature(req);
if (!event) {
return res.status(400).send('Invalid signature');
}
// 2. Store event for idempotency check
const exists = await db.webhookEvent.findUnique({
where: { eventId: event.id },
});
if (exists) {
// Already processed, return success
return res.status(200).send('Already processed');
}
// 3. Queue for async processing
await webhookQueue.add('process-webhook', {
eventId: event.id,
type: event.type,
data: event.data,
});
// 4. Return quickly (within 5-10 seconds)
return res.status(200).send('Accepted');
});
// Worker processes webhooks
const worker = new Worker('webhooks', async (job) => {
const { eventId, type, data } = job.data;
try {
// Process the webhook
await processWebhookEvent(type, data);
// Mark as processed
await db.webhookEvent.create({
data: {
eventId,
type,
processedAt: new Date(),
status: 'completed',
},
});
} catch (error) {
// Mark as failed for retry
await db.webhookEvent.upsert({
where: { eventId },
create: { eventId, type, status: 'failed', error: error.message },
update: { status: 'failed', error: error.message },
});
throw error; // BullMQ will retry
}
});
Best Practice 3: Handle Idempotency 3 min
Webhooks may be delivered multiple times:
| Scenario | Risk | Solution |
|---|---|---|
| Duplicate delivery | Double processing | Track event IDs |
| Out-of-order delivery | Incorrect state | Check timestamps |
| Retry after timeout | Duplicate actions | Return 200 early |
async function processPaymentWebhook(event) {
const paymentId = event.data.object.id;
// Use database transaction with unique constraint
try {
await db.$transaction(async (tx) => {
// Check if already processed
const existing = await tx.payment.findUnique({
where: { stripePaymentId: paymentId },
});
if (existing?.status === 'completed') {
console.log(`Payment ${paymentId} already processed, skipping`);
return;
}
// Process payment
await tx.payment.upsert({
where: { stripePaymentId: paymentId },
create: {
stripePaymentId: paymentId,
amount: event.data.object.amount,
status: 'completed',
processedAt: new Date(),
},
update: {
status: 'completed',
processedAt: new Date(),
},
});
// Fulfill order (only if payment newly completed)
if (!existing || existing.status !== 'completed') {
await fulfillOrder(event.data.object.metadata.orderId);
}
});
} catch (error) {
if (error.code === 'P2002') {
// Unique constraint violation - already processed
console.log('Duplicate webhook, ignoring');
return;
}
throw error;
}
}
Best Practice 4: Validate Webhook Data 4 min
Do not trust webhook payload data blindly:
import { z } from 'zod';
// Define expected webhook schema
const stripePaymentSchema = z.object({
id: z.string().startsWith('evt_'),
type: z.literal('payment_intent.succeeded'),
data: z.object({
object: z.object({
id: z.string().startsWith('pi_'),
amount: z.number().positive(),
currency: z.string().length(3),
customer: z.string().startsWith('cus_').optional(),
metadata: z.object({
orderId: z.string().uuid(),
}),
}),
}),
});
async function handlePaymentWebhook(event) {
// Validate webhook structure
const result = stripePaymentSchema.safeParse(event);
if (!result.success) {
console.error('Invalid webhook structure:', result.error);
throw new Error('Invalid webhook data');
}
const payment = result.data.data.object;
// IMPORTANT: Fetch fresh data from Stripe to verify
// Do not trust amounts/status from webhook alone
const stripePayment = await stripe.paymentIntents.retrieve(payment.id);
if (stripePayment.status !== 'succeeded') {
console.error('Payment not actually succeeded');
throw new Error('Payment status mismatch');
}
// Verify amount matches your records
const order = await db.order.findUnique({
where: { id: payment.metadata.orderId },
});
if (order.expectedAmount !== stripePayment.amount) {
console.error('Amount mismatch - possible tampering');
throw new Error('Amount verification failed');
}
// Safe to process
await fulfillOrder(order.id);
}
Best Practice 5: Secure Webhook Endpoints 3 min
Protect your webhook URLs:
- Use HTTPS only for webhook endpoints
- Add rate limiting to prevent abuse
- Use unpredictable URLs (include random token)
- Log all webhook requests for auditing
- Set appropriate timeouts
import rateLimit from 'express-rate-limit';
// Rate limit webhooks
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests',
});
// Use unpredictable endpoint URL
// Bad: /webhooks/stripe
// Good: /webhooks/stripe/wh_a8f3k2j4m9x7
const WEBHOOK_TOKEN = process.env.WEBHOOK_URL_TOKEN;
app.post(
`/webhooks/stripe/${WEBHOOK_TOKEN}`,
webhookLimiter,
express.raw({ type: 'application/json' }),
async (req, res) => {
// Log for auditing
console.log('Webhook received', {
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
});
// Process webhook...
}
);
// Reject requests to wrong paths
app.post('/webhooks/*', (req, res) => {
res.status(404).send('Not found');
});
Best Practice 6: Handle Failures Gracefully 4 min
Implement retry logic and failure handling:
// BullMQ retry configuration
const webhookQueue = new Queue('webhooks', {
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000, // 1s, 2s, 4s, 8s, 16s
},
removeOnComplete: 1000,
removeOnFail: 5000,
},
});
// Dead letter queue for failed webhooks
const worker = new Worker('webhooks', processWebhook, {
connection: redis,
});
worker.on('failed', async (job, err) => {
if (job.attemptsMade >= job.opts.attempts) {
// Move to dead letter queue
await deadLetterQueue.add('failed-webhook', {
originalJob: job.data,
error: err.message,
failedAt: new Date(),
});
// Alert team
await sendAlert({
severity: 'high',
message: `Webhook processing failed after ${job.attemptsMade} attempts`,
eventId: job.data.eventId,
error: err.message,
});
}
});
External Resources: For more on webhook security, see the Stripe Webhooks Best Practices , GitHub Webhook Signature Verification , and the OWASP Input Validation Cheat Sheet for industry-standard guidance.
What if a provider does not sign webhooks?
Use IP allowlisting if the provider publishes their IP ranges. Add a secret token in the URL path. For critical webhooks, verify the data by calling the provider's API. Consider if you can use polling instead.
How long should I keep webhook logs?
Keep webhook logs for at least 30 days for debugging and dispute resolution. For compliance (payments, subscriptions), keep them longer (90 days to 7 years depending on requirements). Store only necessary data, not full payloads with PII.
Should I use raw body or parsed JSON?
Use raw body for signature verification (parsing can change formatting). Parse JSON only after signature is verified. Most frameworks need special configuration to access raw body alongside parsed body.