Blog / Security

Each platform signs webhook payloads with HMAC-SHA256 using a secret you set in their dashboard. In Node.js: Stripe uses stripe.webhooks.constructEvent(rawBody, sig, secret); Shopify compares X-Shopify-Hmac-SHA256 against crypto.createHmac('sha256', secret).update(rawBody).digest('base64'); GitHub compares X-Hub-Signature-256 against 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex').

Webhook Signature Verification in Node.js: Stripe, Shopify, and GitHub

· 8 min read · Webhook best practices

Your webhook endpoint is a public URL. Anyone with that URL can POST JSON to it and — if you're not verifying signatures — your handler will process it as if it came from Stripe, Shopify, or GitHub. That means a malicious actor can fake a checkout.session.completed event to provision access without paying, or a push event to trigger your CI/CD pipeline.

Signature verification is the one security control that prevents this. Each platform generates an HMAC-SHA256 signature of the payload using a shared secret that only you and they know, and sends it in a request header. Your job is to recompute that signature and verify it matches. Here's exactly how to do it in Node.js for all three platforms.

Why does webhook signature verification matter?

Without verification, your webhook endpoint is a public API that:

HMAC signatures solve this because they require knowledge of the signing secret to produce a valid signature. The secret is only known to you (from your dashboard) and the platform — it never appears in the request itself.

How do you configure Express to preserve the raw request body?

Before writing any signature verification code, you need to solve the most common mistake: all three platforms require you to compute the HMAC over the raw request body bytes, not the parsed JSON object. If you run the body through express.json() first, you lose the original byte sequence and the signature will never match.

Configure Express to expose the raw body:

import express from 'express';

const app = express();

// Store raw body buffer on req for signature verification
app.use(express.raw({
  type: 'application/json',
  verify: (req: any, _res, buf) => {
    req.rawBody = buf;
  },
}));

// If you also need JSON parsing on other routes:
// Mount webhook routes BEFORE express.json() middleware
// or use express.raw() only on webhook-specific routes

Alternatively, use express.raw({ type: 'application/json' }) directly on your webhook routes. This gives you req.body as a Buffer rather than a parsed object.

How do you verify Stripe webhook signatures in Node.js?

Stripe makes this easy with its official SDK. The stripe.webhooks.constructEvent() method handles everything: it parses the Stripe-Signature header, validates the HMAC, and also checks that the timestamp embedded in the signature is within a 5-minute tolerance window (protecting against replay attacks).

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

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

app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'] as string;

    let event: Stripe.Event;
    try {
      // req.body is a Buffer here — do NOT parse it first
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET!
      );
    } catch (err: any) {
      console.error('Stripe signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: {err.message}`);
    }

    // Signature valid — event is authentic
    console.log('Verified Stripe event:', event.type, event.id);
    res.status(200).json({ received: true });
  }
);

The Stripe-Signature header format looks like: t=1714000000,v1=abc123...,v0=def456.... The v1 value is the current signature scheme (HMAC-SHA256 of {timestamp}.{payload}). The SDK handles all of this for you — don't try to parse it manually.

Common mistake: using express.json() middleware before this route. Once the body is parsed to a JS object and re-stringified, the byte sequence changes and the HMAC won't match. Always use express.raw() on Stripe webhook routes.

How do you verify Shopify webhook signatures in Node.js?

Shopify sends the HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header as a base64-encoded string. You compute the same HMAC using the raw body and your Shopify Client Secret, then compare using crypto.timingSafeEqual() to prevent timing attacks.

import crypto from 'crypto';
import express from 'express';

app.post(
  '/webhooks/shopify',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const receivedHmac = req.headers['x-shopify-hmac-sha256'] as string;

    if (!receivedHmac) {
      return res.status(401).send('Missing signature header');
    }

    // Compute expected HMAC — base64-encoded SHA256 of raw body
    const expectedHmac = crypto
      .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET!)
      .update(req.body) // req.body is a Buffer
      .digest('base64');

    // Timing-safe comparison — prevents timing oracle attacks
    const receivedBuf = Buffer.from(receivedHmac, 'base64');
    const expectedBuf = Buffer.from(expectedHmac, 'base64');

    if (
      receivedBuf.length !== expectedBuf.length ||
      !crypto.timingSafeEqual(receivedBuf, expectedBuf)
    ) {
      console.error('Shopify HMAC verification failed');
      return res.status(401).send('Invalid signature');
    }

    // Signature valid — parse body and handle event
    const payload = JSON.parse(req.body.toString('utf8'));
    const topic = req.headers['x-shopify-topic'] as string;
    const webhookId = req.headers['x-shopify-webhook-id'] as string;

    console.log('Verified Shopify event:', topic, webhookId);
    res.status(200).json({ received: true });
  }
);

Note the timingSafeEqual length check: if the two buffers have different lengths, timingSafeEqual will throw a RangeError. Always check lengths first and return 401 if they differ.

Common mistake: using === for string comparison instead of crypto.timingSafeEqual(). A naive string comparison leaks timing information — an attacker can measure response time to infer how many leading bytes of their guessed signature are correct.

How do you verify GitHub webhook signatures in Node.js?

GitHub sends the HMAC-SHA256 signature in the X-Hub-Signature-256 header as a hex string prefixed with sha256=. You compute the same hex HMAC and compare with crypto.timingSafeEqual().

import crypto from 'crypto';
import express from 'express';

app.post(
  '/webhooks/github',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const receivedSig = req.headers['x-hub-signature-256'] as string;

    if (!receivedSig) {
      return res.status(401).send('Missing signature header');
    }

    // Compute expected signature — hex HMAC prefixed with 'sha256='
    const expectedSig =
      'sha256=' +
      crypto
        .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!)
        .update(req.body) // req.body is a Buffer
        .digest('hex');

    // Timing-safe comparison
    const receivedBuf = Buffer.from(receivedSig, 'utf8');
    const expectedBuf = Buffer.from(expectedSig, 'utf8');

    if (
      receivedBuf.length !== expectedBuf.length ||
      !crypto.timingSafeEqual(receivedBuf, expectedBuf)
    ) {
      console.error('GitHub signature verification failed');
      return res.status(401).send('Invalid signature');
    }

    // Signature valid — parse body and handle event
    const payload = JSON.parse(req.body.toString('utf8'));
    const eventType = req.headers['x-github-event'] as string;
    const deliveryId = req.headers['x-github-delivery'] as string;

    console.log('Verified GitHub event:', eventType, deliveryId);
    res.status(200).json({ received: true });
  }
);

Common mistake 1: forgetting the sha256= prefix when computing expectedSig. GitHub's header value is sha256=abc123..., not just abc123.... If you compare the raw hex to the prefixed header value, it will always fail.

Common mistake 2: GitHub also sends a legacy X-Hub-Signature header using SHA1. Never use SHA1 — only verify against X-Hub-Signature-256.

What are the most common signature verification mistakes?

FAQ: Webhook signature verification in Node.js

How does Stripe webhook signature verification work?
Stripe computes an HMAC-SHA256 of {timestamp}.{raw_payload} using your endpoint's signing secret and sends the result in the Stripe-Signature header alongside the timestamp. In Node.js, call stripe.webhooks.constructEvent(rawBody, req.headers['stripe-signature'], signingSecret). The SDK validates the HMAC and rejects requests older than 5 minutes to prevent replay attacks. You must pass the raw body buffer — not parsed JSON.
How do I verify Shopify webhook signatures in Node.js?
Shopify sends a base64-encoded HMAC-SHA256 in the X-Shopify-Hmac-SHA256 header. Compute crypto.createHmac('sha256', SHOPIFY_CLIENT_SECRET).update(rawBodyBuffer).digest('base64'), then compare using crypto.timingSafeEqual() — never with ===. Always compare Buffer objects of equal length to avoid RangeError.
What is the X-Hub-Signature-256 header from GitHub?
It's GitHub's webhook signature header, containing an HMAC-SHA256 of the raw request body computed with your webhook secret, in the format sha256={hex_digest}. In Node.js, compute 'sha256=' + crypto.createHmac('sha256', secret).update(rawBodyBuffer).digest('hex') and compare using crypto.timingSafeEqual(). Don't use the legacy X-Hub-Signature (SHA1) header.

Secure webhooks start with signature verification — and reliable webhooks start with monitoring. Webhook Guardian detects delivery failures across Stripe, Shopify, and GitHub and alerts you within 5 minutes. Start free for 14 days →

Also read: Webhook security guide · Webhook reliability best practices · Stripe monitoring · Shopify monitoring · GitHub monitoring