Webhook Security Best Practices: Validation, Signatures, and Safe Processing

Share

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:

Stripe webhook verification
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 });
}
Generic HMAC signature verification
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:

Async webhook processing
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:

ScenarioRiskSolution
Duplicate deliveryDouble processingTrack event IDs
Out-of-order deliveryIncorrect stateCheck timestamps
Retry after timeoutDuplicate actionsReturn 200 early
Idempotent webhook handling
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:

Webhook data validation
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
Secure webhook endpoint setup
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:

Webhook 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.

Check Your Webhook Security

Scan for webhook vulnerabilities and misconfigurations.

Start Free Scan
Best Practices

Webhook Security Best Practices: Validation, Signatures, and Safe Processing