DEV Community

Mean for APIKumo

Posted on

Webhook Verification: How to Validate Every Incoming Request (and Why You Must)

Webhook Verification: How to Validate Every Incoming Request (and Why You Must)

Webhooks are the backbone of event-driven integrations — Stripe charges, GitHub pushes, Slack commands, Shopify orders. They're simple on the surface: a third-party service sends an HTTP POST to your endpoint when something happens.

The problem? Anyone on the internet can POST to your webhook endpoint. Without verification, a malicious actor can send fake events and trick your system into shipping an order, granting permissions, or triggering a refund. Webhook signature verification is non-negotiable — yet it's often the last thing developers add (if they add it at all).

Here's how to do it right, with real examples.


How Webhook Signatures Work

Most services that send webhooks sign their payloads using HMAC-SHA256. The flow is:

  1. You register your endpoint URL with the service.
  2. The service gives you a secret key (or you generate one).
  3. When the service sends a webhook, it computes HMAC-SHA256(secret, raw_body) and includes the result in a request header (e.g. X-Hub-Signature-256, Stripe-Signature, X-Shopify-Hmac-SHA256).
  4. Your server recomputes the same HMAC and compares it to the header. If they match, the request is authentic.

The critical rule: always compute the HMAC against the raw request body — before any JSON parsing. Parsing and re-serializing can change whitespace and key order, breaking the signature check.


Verifying a Stripe Webhook (Node.js)

Stripe adds a timestamp and a signature to the Stripe-Signature header to protect against replay attacks.

import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();

// IMPORTANT: use raw body parser for webhook routes
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error(`Signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the verified event
  switch (event.type) {
    case 'payment_intent.succeeded':
      console.log('Payment succeeded:', event.data.object.id);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Stripe's SDK handles replay-attack protection by rejecting events with a timestamp older than 5 minutes by default.


Verifying a GitHub Webhook (Python)

GitHub uses the X-Hub-Signature-256 header with HMAC-SHA256.

import hashlib
import hmac
import os
from flask import Flask, request, abort

app = Flask(__name__)
GITHUB_SECRET = os.environ['GITHUB_WEBHOOK_SECRET'].encode()

def verify_github_signature(payload_body: bytes, signature_header: str) -> bool:
    if not signature_header or not signature_header.startswith('sha256='):
        return False
    expected = 'sha256=' + hmac.new(
        GITHUB_SECRET, payload_body, hashlib.sha256
    ).hexdigest()
    # Use compare_digest to prevent timing attacks
    return hmac.compare_digest(expected, signature_header)

@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
    signature = request.headers.get('X-Hub-Signature-256', '')
    if not verify_github_signature(request.data, signature):
        abort(400, 'Invalid signature')

    payload = request.get_json()
    event = request.headers.get('X-GitHub-Event')
    print(f'Received {event} event for {payload.get("repository", {}).get("full_name")}')
    return '', 204
Enter fullscreen mode Exit fullscreen mode

Note the use of hmac.compare_digest — this is essential to prevent timing attacks, where an attacker could infer the correct signature by measuring how long comparisons take.


A Generic HMAC Verifier

If you're working with a service that doesn't have an official SDK, here's a reusable helper:

import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhookSignature(
  rawBody: Buffer,
  receivedSig: string,
  secret: string,
  algorithm: 'sha256' | 'sha1' = 'sha256'
): boolean {
  const expectedSig = createHmac(algorithm, secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(receivedSig.replace(/^sha\d+=/, ''), 'hex');
  const b = Buffer.from(expectedSig, 'hex');

  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes to Avoid

1. Parsing the body before verifying. JSON parsers can normalize whitespace or reorder keys. Always verify against the raw bytes.

2. Using === for signature comparison. This leaks timing information. Always use timingSafeEqual / hmac.compare_digest or equivalent.

3. No replay protection. If your service doesn't handle this automatically (like Stripe does), store and check event IDs or validate timestamps.

4. Hardcoding secrets. Store webhook secrets in environment variables or a secrets manager, never in source code.


Testing Webhook Verification Locally

Webhook verification is notoriously hard to test because you need a public URL. Tools like ngrok or smee.io can tunnel traffic to your local machine during development.

This is where having a proper API client environment pays off. APIKumo lets you replay saved webhook payloads against your local endpoint — including the original headers and signatures — so you can iterate on your verification logic without needing to trigger real events from the third-party service every time.


Webhook verification is one of those things that takes 20 minutes to add and can save you from a catastrophic security incident. Add it at day one, not after your first production incident.

Top comments (0)