Launch special — let's split the check with SPLITCHECK for 50% off
8 min read

Webhook Security Best Practices: HMAC Signature Verification

Secure webhook endpoints with HMAC signature verification. Step-by-step code for Stripe, GitHub, Shopify, timing-safe compare, and secret rotation.

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.

Frequently Asked Questions

What is HMAC webhook signature verification?

HMAC signature verification is a technique where the webhook sender uses a shared secret to compute an HMAC hash of the request body, and sends the hash in a header. The receiver recomputes the same hash and compares. If they match, the request is authentic. HMAC-SHA256 is the standard algorithm used by Stripe, GitHub, Shopify, and most modern providers.

Why do I need a timing-safe comparison for HMAC?

A regular string comparison returns false at the first mismatched byte, so an attacker can measure response times to guess the signature byte by byte. A timing-safe comparison (like crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) always takes the same time regardless of where the mismatch occurs, preventing this attack.

Is HMAC verification enough to secure my webhook endpoint?

HMAC verification confirms the request came from your provider and was not modified in transit, which is the most important protection. You should also: validate timestamps to prevent replay attacks, use HTTPS, rotate secrets periodically, and store secrets outside source control. HMAC alone does not protect against a leaked secret.

How do I rotate a webhook signing secret safely?

Most providers (Stripe, GitHub, Shopify) support two active secrets during rotation. Generate a new secret, start accepting both old and new signatures in your verification code, update the provider to send with the new secret, confirm new-secret traffic is flowing, then revoke the old secret. This avoids dropped events during the switchover.

What happens if I skip signature verification?

Any attacker who learns your webhook URL can send crafted requests to trigger actions in your system — fake payments, bogus orders, malicious data injection. Webhook URLs leak through logs, browser history, and network captures. Signature verification is the only reliable defense against spoofing.

Related

Related posts

Try HookSense Free

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

Get Started Free