Early access — use code HOOKSENSEWELCOME for 1 month free
10 min read

Webhook Security Best Practices: HMAC Signature Verification

Protect your webhook endpoints with HMAC signature verification. Covers Stripe, GitHub, and Shopify examples plus common security pitfalls to avoid.

SecurityHMACBest Practices
O

Ozer

Developer & Founder of HookSense

Your webhook endpoint is a publicly accessible URL. Anyone who discovers it can send crafted HTTP requests to trigger actions in your system — fake payment confirmations, bogus order notifications, or malicious data injection. Webhook signature verification is not optional; it is the single most important security measure for any webhook integration.

This guide covers HMAC signature verification in depth: how it works under the hood, step-by-step implementation for Stripe, GitHub, and Shopify, and the security pitfalls that catch even experienced developers.

Why Webhook Security Matters

Consider this scenario: you build an e-commerce site that fulfills orders when Stripe sends a checkout.session.completed webhook. Without signature verification, an attacker can:

  • Send a fake webhook to your endpoint with a fabricated order
  • Your server processes it as a legitimate purchase
  • You ship a product without ever receiving payment

This is not theoretical. Webhook spoofing attacks happen regularly, and they are trivially easy to execute — a single curl command is all it takes. HMAC signature verification prevents this entirely.

How HMAC Works

HMAC (Hash-based Message Authentication Code) is a cryptographic construction that combines a hash function with a secret key. Here is the process:

  1. Shared secret: When you create a webhook endpoint, the provider gives you a signing secret (e.g., Stripe's whsec_...). This secret is known only to you and the provider.
  2. Signing: Before sending the webhook, the provider computes HMAC-SHA256(secret, payload) where payload is the raw HTTP request body. The resulting hash is included in a request header.
  3. Verification: Your server receives the request, computes the same HMAC using your copy of the secret and the received body, then compares the result with the header value.
  4. Decision: If the values match, the request is authentic and untampered. If they differ, reject the request with a 401 status.

The beauty of HMAC is that an attacker cannot forge a valid signature without knowing the secret. Even a single byte change in the payload produces a completely different hash.

Stripe Implementation

Stripe uses a timestamp-based signature scheme. The Stripe-Signature header contains both a timestamp (t) and one or more signatures (v1):

Stripe-Signature: t=1614556828,v1=abc123def456...

The signed payload is constructed as timestamp.body, which prevents replay attacks. Here is the complete implementation:

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

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

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, endpointSecret);
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Signature verified — safe to process
    switch (event.type) {
      case 'checkout.session.completed':
        handleCheckoutComplete(event.data.object);
        break;
      case 'invoice.payment_failed':
        handlePaymentFailed(event.data.object);
        break;
    }

    res.json({ received: true });
  }
);

Critical detail: The express.raw() middleware ensures the body is passed as a raw Buffer. If you use express.json() first, the body gets parsed and re-serialized, changing the bytes and breaking the signature.

GitHub Implementation

GitHub uses the X-Hub-Signature-256 header with a straightforward sha256=HASH format:

const crypto = require('crypto');

function verifyGitHubWebhook(req, secret) {
  const signature = req.headers['x-hub-signature-256'];
  if (!signature) return false;

  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret)
      .update(req.rawBody)
      .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.post('/webhooks/github', (req, res) => {
  if (!verifyGitHubWebhook(req, process.env.GITHUB_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

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

  switch (event) {
    case 'push':
      handlePush(payload);
      break;
    case 'pull_request':
      handlePullRequest(payload);
      break;
  }

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

Shopify Implementation

Shopify uses the X-Shopify-Hmac-SHA256 header with a Base64-encoded signature:

function verifyShopifyWebhook(req, secret) {
  const signature = req.headers['x-shopify-hmac-sha256'];
  if (!signature) return false;

  const computed = crypto.createHmac('sha256', secret)
    .update(req.rawBody, 'utf8')
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(signature, 'base64'),
    Buffer.from(computed, 'base64')
  );
}

Note the base64 encoding — this is different from Stripe and GitHub which use hexadecimal. Always check the provider's documentation for the expected encoding format.

Security Pitfalls to Avoid

1. String Comparison Instead of Constant-Time Comparison

Never compare signatures with === or ==. Standard string comparison exits early when it finds a mismatched character, which leaks timing information. An attacker can use this to reconstruct the valid signature one character at a time.

// INSECURE — vulnerable to timing attacks
if (computedSignature === headerSignature) { ... }

// SECURE — constant-time comparison
if (crypto.timingSafeEqual(
  Buffer.from(computedSignature),
  Buffer.from(headerSignature)
)) { ... }

2. Not Validating Timestamps

Stripe includes a timestamp in the signature to prevent replay attacks. Always reject requests where the timestamp is more than 5 minutes old:

const tolerance = 300; // 5 minutes in seconds
const timestampAge = Math.floor(Date.now() / 1000) - timestamp;
if (timestampAge > tolerance) {
  throw new Error('Webhook timestamp too old');
}

3. Storing Secrets in Code

Never hardcode webhook secrets in your source code. Use environment variables, a secrets manager (AWS Secrets Manager, HashiCorp Vault), or your platform's secret management (Vercel Environment Variables, Heroku Config Vars).

4. Skipping Verification in Development

It is tempting to bypass signature verification during development because you are testing with tools that do not sign requests. This is dangerous because the code path that skips verification can leak into production.

Instead, use HookSense, which supports built-in HMAC verification. Configure your webhook secret in the endpoint settings, and HookSense shows a "Verified" or "Invalid" badge on every request — so you can validate signatures without writing verification code during development.

5. Not Rotating Secrets

If a webhook secret is exposed (leaked in logs, committed to git, shared in a chat), rotate it immediately. During rotation, temporarily accept both the old and new secret to avoid dropping legitimate events:

function verifyWithFallback(body, signature, secrets) {
  return secrets.some(secret => {
    try {
      stripe.webhooks.constructEvent(body, signature, secret);
      return true;
    } catch {
      return false;
    }
  });
}

Testing Signature Verification

You can generate test signatures locally to verify your implementation works:

const crypto = require('crypto');

const secret = 'whsec_test_secret';
const payload = JSON.stringify({ type: 'test', data: {} });
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;

const signature = crypto.createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');

console.log(`Stripe-Signature: t=${timestamp},v1=${signature}`);

Automatic Verification with HookSense

During development, manually testing signature verification is tedious. HookSense automates this entirely:

  1. Create an endpoint at hooksense.com
  2. Open endpoint settings and click the Shield icon
  3. Paste your webhook signing secret
  4. Select the provider (Stripe, GitHub, Shopify, or custom HMAC-SHA256)
  5. Every incoming request displays a verification badge instantly

This lets you confirm that your webhook provider is sending correctly signed requests before you invest time implementing verification in your own code.

Summary

Webhook signature verification is non-negotiable. Use HMAC-SHA256 with constant-time comparison, validate timestamps where available, never skip verification in any environment, and rotate secrets promptly when compromised. With proper verification in place, your webhook endpoints are protected against spoofing, tampering, and replay attacks.

Related

Try HookSense Free

Inspect, debug, and replay webhooks in real-time. No credit card required.

Get Started Free