Webhooks

How to Verify Webhook Signatures

Anyone can POST to your webhook URL. Here's how to make sure the request actually came from who you think it did.

April 20258 min read

Your Webhook URL Is a Public Endpoint — Act Accordingly

When you register a webhook URL with Stripe, GitHub, or any other service, that URL goes out into the world. It's on your public server. Nothing stops someone from finding it and sending their own POST requests to it.

Think about what that means in practice. If you're building an e-commerce site and you process a payment_intent.succeeded event by fulfilling an order — an attacker who knows your webhook URL could POST a fake success event and get free products. No payment required.

This isn't a theoretical threat. It's exactly why every serious webhook provider includes a mechanism for you to verify that a request genuinely came from them, not from someone impersonating them. The mechanism is almost universally HMAC-SHA256 — a cryptographic signature you can verify without any network calls, using only the request body and a secret key you already have.

The one-sentence version:

The sender hashes the request body with a shared secret, attaches the hash to the request headers, and you verify it by computing the same hash yourself and comparing the two.

How HMAC-SHA256 Actually Works

HMAC stands for Hash-based Message Authentication Code. The core idea is beautifully simple: you take a message (the request body) and a secret key, combine them in a specific way, and run the result through a cryptographic hash function (SHA-256). The output is a fixed-length string that acts as a fingerprint of both the message and the key.

Here's why it works for webhook verification. Only two parties know the secret key — the sender (e.g. Stripe) and you. When Stripe sends a webhook, they compute HMAC-SHA256(secret, payload) and attach the result to the request. On your end, you compute the same thing with the same secret. If your hash matches theirs, the payload is genuine and hasn't been tampered with. If it doesn't match, something is wrong — either the payload was modified in transit, or the request didn't come from Stripe at all.

An attacker who doesn't have your secret key cannot produce a valid signature, no matter how many requests they send. That's the security guarantee.

// The core idea — pseudocode
const expectedSignature = HMAC_SHA256(webhookSecret, requestBody);
const receivedSignature = req.headers['x-webhook-signature'];

if (expectedSignature === receivedSignature) {
  // Legit — process the event
} else {
  // Reject it
  return res.status(401).send('Invalid signature');
}

The concept is simple. The implementation details are where people trip up — and they matter a lot. Let's look at how two major services implement this, because the exact mechanics differ and getting them wrong means your verification silently fails.

Stripe's Signature Verification

Stripe's webhook signature documentation is thorough, and their approach is more sophisticated than a plain HMAC because it also protects against replay attacks. Every webhook request from Stripe includes a Stripe-Signature header that looks like this:

Stripe-Signature: t=1681234567,v1=abc123def456...,v0=oldformat...

The t= value is a Unix timestamp of when Stripe sent the request. The v1= value is the HMAC-SHA256 signature. Stripe's signature is computed over a concatenated string: {timestamp}.{raw_payload} — so the timestamp is actually baked into the signature itself.

The easiest way to verify Stripe webhooks in Node.js is with their official library, which handles all of this for you:

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

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

// IMPORTANT: use express.raw() here, not express.json()
// Parsing the body as JSON before verification will break it
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,          // raw Buffer — not parsed JSON
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error('Signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Signature verified — safe to process
    if (event.type === 'payment_intent.succeeded') {
      const paymentIntent = event.data.object;
      // fulfill order, send email, etc.
    }

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

If you'd rather not use the Stripe SDK (or you want to understand what constructEvent() is actually doing), here's the manual version:

import crypto from 'crypto';

function verifyStripeSignature(payload, sigHeader, secret) {
  const parts = sigHeader.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const receivedSig = parts.find(p => p.startsWith('v1=')).slice(3);

  // Stripe signs the timestamp + '.' + raw payload
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks (more on this below)
  const expected = Buffer.from(expectedSig, 'hex');
  const received = Buffer.from(receivedSig, 'hex');

  if (expected.length !== received.length) return false;
  if (!crypto.timingSafeEqual(expected, received)) return false;

  // Also check the timestamp to reject replayed requests
  const tolerance = 300; // 5 minutes
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > tolerance) {
    throw new Error('Webhook timestamp too old — possible replay attack');
  }

  return true;
}

The timestamp check is not optional

A valid signature from an old request is still a valid signature. Without checking the timestamp, an attacker who captures a legitimate webhook request can replay it hours later. Stripe's SDK rejects requests older than 5 minutes by default. Keep that tolerance tight in your own implementation too.

GitHub's Signature Verification

GitHub's approach is simpler than Stripe's — they use the X-Hub-Signature-256 header with a plain HMAC-SHA256 over the raw request body. No timestamp in the mix, just the payload and your secret.

X-Hub-Signature-256: sha256=abc123def456...

Note the sha256= prefix — GitHub always includes it. Here's a verification function for Node.js using the Node.js crypto module:

import crypto from 'crypto';

function verifyGithubSignature(rawBody, sigHeader, secret) {
  if (!sigHeader || !sigHeader.startsWith('sha256=')) {
    return false;
  }

  const receivedSig = sigHeader.slice(7); // strip 'sha256=' prefix
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // timingSafeEqual requires both Buffers to be the same length
  const expected = Buffer.from(expectedSig, 'hex');
  const received = Buffer.from(receivedSig, 'hex');

  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

// In your Express route
app.post('/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['x-hub-signature-256'];
    if (!verifyGithubSignature(req.body, sig, process.env.GITHUB_WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

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

    if (event === 'push') {
      // trigger your CI pipeline
    }

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

Building a Webhook Sender — How to Sign Outgoing Requests

If you're building a platform that sends webhooks to your users (rather than receiving them from others), you'll want to implement the same pattern from the other side. The approach is straightforward: generate a secret per webhook endpoint, store it alongside the endpoint URL, and include the signature on every delivery.

import crypto from 'crypto';

function signWebhookPayload(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const body = typeof payload === 'string' ? payload : JSON.stringify(payload);

  // Stripe-style: sign timestamp + '.' + body
  const signedPayload = `${timestamp}.${body}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  return {
    'Content-Type': 'application/json',
    'X-Webhook-Timestamp': timestamp,
    'X-Webhook-Signature': `v1=${signature}`,
  };
}

async function deliverWebhook(endpointUrl, event, secret) {
  const body = JSON.stringify(event);
  const headers = signWebhookPayload(body, secret);

  const response = await fetch(endpointUrl, {
    method: 'POST',
    headers,
    body,
  });

  if (!response.ok) {
    throw new Error(`Webhook delivery failed: ${response.status}`);
  }
}

Document the signing scheme clearly for your users — what header name you use, whether you include a timestamp, and how they should verify it. When they're debugging a new integration, being able to paste the raw payload into a JSON formatter and see exactly what fields arrived is genuinely helpful — save them that friction by being explicit about your payload structure.

The Mistakes That Will Silently Break Your Verification

Signature verification is one of those things that looks like it works during testing and then fails in production in a way that's genuinely confusing to debug. Here are the most common reasons.

1

Parsing the body before verifying it

This is the most common mistake. The signature was computed over the exact bytes of the raw request body. The moment you parse the JSON and re-stringify it, you may change the byte order, whitespace, or encoding. Your HMAC will be computed over different bytes than Stripe's were, and verification will fail every time.

In Express, use express.raw() on your webhook route, not express.json(). Verify the signature first, then parse the body yourself with JSON.parse(req.body.toString()). If you have global JSON middleware, you need to exclude the webhook path from it.

2

Using === instead of timingSafeEqual

When you compare two strings with ===, JavaScript short-circuits as soon as it finds a character that doesn't match. This creates a timing attack vulnerability: a sufficiently precise attacker can measure how long your comparison takes and infer how many characters of the signature they got right, then brute-force their way to a valid one character at a time.

crypto.timingSafeEqual() always takes the same amount of time regardless of where (or whether) the values differ. It's a two-line change that eliminates the entire attack surface. Always use it for signature comparison.

3

Comparing Buffer lengths before timingSafeEqual

timingSafeEqual throws if the two Buffers are different lengths — it doesn't return false, it throws. Always check that your expected and received values are the same byte length before calling it. If someone sends a garbage signature that's only 3 characters, an uncaught exception will return a 500 rather than a 401, which is the wrong behavior.

4

Rotating your secret without a transition window

If you rotate your webhook secret and the provider is mid-delivery of a batch of events, those in-flight requests will suddenly fail verification. Stripe lets you have two active secrets simultaneously during a rotation window for exactly this reason. If you're rolling your own, consider accepting signatures from both the old and new secret during a short overlap period.

What About Verification Outside Node.js?

The same HMAC-SHA256 logic works in any language. Python has hmac.compare_digest() for timing-safe comparison. Go has hmac.Equal(). Ruby has Rack::Utils.secure_compare(). The concept is identical regardless of your stack.

If you ever need to do this in a browser context — perhaps you're building a serverless edge function or a browser-based webhook test tool — the Web Crypto API covers you. SubtleCrypto.sign() supports HMAC-SHA256 natively, though the API is promise-based and uses ArrayBuffers, which makes it slightly more verbose to work with than the Node.js crypto module.

// Browser / Cloudflare Workers / Deno
async function verifyHmacSha256(secret, message, expectedHex) {
  const encoder = new TextEncoder();
  const keyData = encoder.encode(secret);
  const msgData = encoder.encode(message);

  const key = await crypto.subtle.importKey(
    'raw', keyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false, ['sign']
  );

  const sigBuffer = await crypto.subtle.sign('HMAC', key, msgData);
  const sigHex = Array.from(new Uint8Array(sigBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // Constant-time comparison via timing-safe approach
  return sigHex === expectedHex; // note: not timing-safe — for full safety, use a worker-side solution
}

Never put your webhook secret in client-side code

Webhook signature verification should always happen on your server. The secret key cannot be exposed to the browser — if it is, the whole scheme falls apart. The SubtleCrypto example above is relevant for edge runtimes and server-side environments, not browser pages.

Quick Reference: Signature Headers by Provider

Every provider does this slightly differently. Here's a cheat sheet for the most common ones:

Stripe

Header: Stripe-Signature

Format: t=timestamp,v1=signature

Signed payload: timestamp.rawbody

GitHub

Header: X-Hub-Signature-256

Format: sha256=signature

Signed payload: raw body only

Shopify

Header: X-Shopify-Hmac-SHA256

Format: base64-encoded (not hex)

Signed payload: raw body only

Twilio

Header: X-Twilio-Signature

Format: base64-encoded HMAC-SHA1

Signed payload: URL + sorted params

Shopify and Twilio are both worth calling out because they use base64 encoding instead of hex, and Twilio uses SHA-1 (older) rather than SHA-256. Always check the specific provider docs rather than assuming the format. When you're comparing payloads during debugging, JsonParser.ai makes it easy to explore the structure of an unfamiliar payload before you write the handler.

Keep Reading