DEV Community

Henry Hang
Henry Hang

Posted on • Originally published at hookcap.dev

How to Secure Webhooks: HMAC Verification and Best Practices

How to Secure Webhooks: HMAC Verification and Best Practices

Every major webhook provider -- Stripe, GitHub, Shopify, Twilio, Discord -- sends a signature with each webhook delivery. Most developers skip verifying it during development and never add it in production. That is a serious security mistake.

Without signature verification, anyone who discovers your webhook endpoint URL can send fake events to your server: forged payment confirmations, fabricated order updates, spoofed user signups. This guide explains how webhook signatures work and how to implement proper verification in your handler.

Why Webhook Signature Verification Matters

Your webhook endpoint is a public URL. If it accepts unauthenticated POST requests, an attacker can:

  • Send a fake payment_intent.succeeded event to trigger order fulfillment without paying
  • Inject malicious payloads that exploit your parsing logic
  • Flood your endpoint with events to exhaust rate limits or cause downstream issues

Signature verification solves this by proving that a webhook was sent by the service you expected, not a third party.

How HMAC Signatures Work

Most webhook providers use HMAC-SHA256 signatures. Here is the process:

  1. The provider takes the raw request body
  2. Signs it with a secret key using HMAC-SHA256
  3. Sends the signature in a request header (e.g., Stripe-Signature, X-Hub-Signature-256, X-Shopify-Hmac-SHA256)
  4. Your server recomputes the signature using the same secret and compares

If the signatures match, the request is authentic. If they do not match, reject it with a 400 or 401.

Provider Server                    Your Server
     |                                  |
     |  POST /webhook                   |
     |  Body: {"event": "payment..."}   |
     |  Stripe-Signature: t=...,v1=...  |
     |--------------------------------->|
     |                                  |
     |                                  | 1. Extract raw body
     |                                  | 2. Extract signature from header
     |                                  | 3. Compute HMAC-SHA256(secret, body)
     |                                  | 4. Compare computed vs received
     |                                  | 5. Accept or reject
Enter fullscreen mode Exit fullscreen mode

The key word is raw body. If you parse the JSON first and then re-serialize it, the byte-level content may change and the signature comparison will fail. Always sign and verify against the original raw bytes.

Stripe: Signature Verification

Stripe sends a Stripe-Signature header with a timestamp and multiple signatures. The timestamp prevents replay attacks (where an attacker captures a legitimate webhook and re-sends it later).

Stripe-Signature: t=1712345678,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd445be62536974f39a
Enter fullscreen mode Exit fullscreen mode

Node.js (Express)

const express = require('express');
const crypto = require('crypto');

const app = express();

// IMPORTANT: use raw body, not parsed JSON
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  // Parse the Stripe-Signature header
  const parts = sig.split(',').reduce((acc, part) => {
    const [key, value] = part.split('=');
    acc[key] = value;
    return acc;
  }, {});

  const timestamp = parts.t;
  const receivedSig = parts.v1;

  // Compute the expected signature
  const payload = `${timestamp}.${req.body}`;
  const expectedSig = crypto
    .createHmac('sha256', webhookSecret)
    .update(payload, 'utf8')
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const sigBuffer = Buffer.from(receivedSig, 'hex');
  const expectedBuffer = Buffer.from(expectedSig, 'hex');

  if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
    return res.status(400).send('Invalid signature');
  }

  // Check timestamp to prevent replay attacks (5 minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(400).send('Timestamp too old');
  }

  const event = JSON.parse(req.body);
  // Handle event...

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

Stripe also provides an official SDK that handles this for you:

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

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

Python (Flask)

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

app = Flask(__name__)

@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()  # raw bytes
    sig_header = request.headers.get('Stripe-Signature', '')
    webhook_secret = os.environ['STRIPE_WEBHOOK_SECRET'].encode('utf-8')

    # Parse the Stripe-Signature header
    parts = dict(part.split('=', 1) for part in sig_header.split(','))
    timestamp = parts.get('t', '')
    received_sig = parts.get('v1', '')

    # Compute expected signature
    signed_payload = f"{timestamp}.".encode('utf-8') + payload
    expected_sig = hmac.new(webhook_secret, signed_payload, hashlib.sha256).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(expected_sig, received_sig):
        abort(400, 'Invalid signature')

    event = json.loads(payload)
    # Handle event...

    return {'received': True}
Enter fullscreen mode Exit fullscreen mode

GitHub: Webhook Signature Verification

GitHub uses the X-Hub-Signature-256 header with a simpler format: sha256=<hex_signature>.

app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-hub-signature-256'];
  if (!sig) {
    return res.status(401).send('No signature');
  }

  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  const sigBuffer = Buffer.from(sig);
  const expectedBuffer = Buffer.from(expected);

  if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
    return res.status(401).send('Invalid signature');
  }

  const payload = JSON.parse(req.body);
  const event = req.headers['x-github-event'];

  // Handle event based on type...
  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Go: Generic HMAC Verification

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verifyHMAC(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    // Use hmac.Equal for constant-time comparison
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    sig := r.Header.Get("X-Hub-Signature-256")
    // Strip "sha256=" prefix if present
    if len(sig) > 7 && sig[:7] == "sha256=" {
        sig = sig[7:]
    }

    if !verifyHMAC(body, sig, os.Getenv("WEBHOOK_SECRET")) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Process webhook...
    w.WriteHeader(http.StatusOK)
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

1. Parsing JSON Before Verification

// WRONG: body-parser changes the raw bytes
app.use(express.json());
app.post('/webhook', (req, res) => {
  // req.body is already parsed — signature check will fail
  verifySignature(JSON.stringify(req.body), sig, secret); // don't do this
});

// CORRECT: keep raw bytes for webhook routes
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  verifySignature(req.body, sig, secret); // req.body is a Buffer
});
Enter fullscreen mode Exit fullscreen mode

2. String Comparison Instead of Constant-Time

// WRONG: timing attack possible
if (computedSig === receivedSig) { ... }

// CORRECT: constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(computedSig), Buffer.from(receivedSig))) { ... }
Enter fullscreen mode Exit fullscreen mode

Timing attacks are theoretical but real — use constant-time comparison whenever comparing secrets.

3. No Replay Attack Protection

A valid signature only proves the request was sent by the right party. It does not prevent replay: an attacker could capture a legitimate webhook delivery and re-send it hours later. To prevent this, check the timestamp in the signature and reject requests older than a few minutes (typically 5 minutes).

4. Storing the Secret in Source Code

Use environment variables or a secrets manager. Never commit webhook secrets to your repository.

Testing Signature Verification with HookCap

Before going live, use HookCap to inspect and replay webhook deliveries and verify your signature logic handles real payloads correctly.

  1. Create a HookCap endpoint — you get a persistent HTTPS URL
  2. Configure that URL as your webhook destination in Stripe/GitHub/Shopify
  3. Trigger real events from the provider dashboard
  4. Inspect the raw headers, including the signature header
  5. Use HookCap's replay feature to resend a captured payload to your local server

This is especially useful for debugging signature failures — you can see the exact header value the provider sent and compare it against what your code computed.

HookCap's Auto-Forward feature (Pro) also lets you forward captured webhooks directly to localhost, so you can test your local handler with real production payloads without any tunnel setup.

Shopify and Twilio Verification

The same pattern applies to other providers:

ShopifyX-Shopify-Hmac-SHA256 contains a base64-encoded (not hex) HMAC-SHA256 signature:

const crypto = require('crypto');

function verifyShopifyWebhook(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('base64');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Enter fullscreen mode Exit fullscreen mode

Twilio — uses its own X-Twilio-Signature header with a slightly different algorithm. Twilio signs a concatenation of the URL and sorted POST parameters, not just the body. Use the official Twilio Node.js SDK's validateRequest helper.

Summary

Webhook signature verification is not optional for production systems. The steps are always the same:

  1. Read the raw request body — do not parse it first
  2. Extract the signature from the appropriate header
  3. Recompute the HMAC using your webhook secret
  4. Compare using constant-time equality
  5. Check the timestamp if the provider includes one

Each provider has its own header name and sometimes its own signing algorithm details, but the underlying concept is identical. Pick up the SDK when the provider has one — it handles the edge cases for you.

Top comments (0)