How to Verify a Webhook Signature (HMAC SHA256)

Verify webhook signatures so you only trust authentic requests. How HMAC SHA256 signing works, how GitHub, Stripe and Shopify do it, a Node.js example, and a free verifier.

Your webhook endpoint is a public URL. That means anyone who discovers it can send it requests — including fake ones. Signature verification is how you make sure a webhook genuinely came from the provider and wasn't tampered with in transit. Skip it, and an attacker could forge a payment.succeeded or subscription.created event.

This guide explains how HMAC signing works, how the major providers implement it, and how to verify it correctly.

How webhook signing works

When a provider sends a webhook, it computes an HMAC — a hash of the request body mixed with a secret only you and the provider know — and puts the result in a header. You recompute the same HMAC on your side and compare. If they match, the body is authentic and unmodified, because an attacker can't produce a valid hash without the secret.

The steps are always the same:

  1. Read the raw request body (the exact bytes, not re-serialized JSON).
  2. Compute HMAC-SHA256(secret, rawBody).
  3. Compare it to the signature header using a constant-time comparison.

How the major providers do it

ProviderHeaderAlgorithmNotes
GitHubX-Hub-Signature-256HMAC-SHA256, hex, prefixed sha256=Strip the sha256= prefix before comparing
StripeStripe-SignatureHMAC-SHA256 over timestamp.bodyHeader contains t= and v1=; check the timestamp to prevent replays
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256, base64Encode your digest as base64, not hex
SlackX-Slack-SignatureHMAC-SHA256 over v0:timestamp:bodyVersioned signing string

A Node.js example

const crypto = require('crypto')

// rawBody must be the exact bytes you received (Buffer or string), not JSON.parse'd output.
function verifyGitHub(rawBody, signatureHeader, secret) {
  const digest = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')

  // constant-time comparison avoids timing attacks
  const a = Buffer.from(digest)
  const b = Buffer.from(signatureHeader || '')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

For Shopify, swap .digest('hex') for .digest('base64') and drop the sha256= prefix. For Stripe, build the signed payload as ${timestamp}.${rawBody} and compare against the v1 value.

Test your verification with a free tool

Before wiring this into production, confirm your inputs line up. Paste the raw body, the secret and the expected signature into the free HMAC generator & signature verifier — it computes the HMAC for SHA256, SHA512, SHA1 or MD5 and tells you whether it matches the provider's value. It's the fastest way to rule out "is my secret right?" before debugging code.

Common mistakes

  • Verifying the parsed body. Frameworks often parse JSON before you see it; re-serializing changes the bytes and breaks the signature. Capture the raw body.
  • Using == to compare. Use a constant-time comparison (crypto.timingSafeEqual) to avoid timing attacks.
  • Wrong encoding. GitHub is hex, Shopify is base64 — mixing them up fails silently.
  • Ignoring the timestamp. For Stripe/Slack, reject signatures with an old timestamp to prevent replay attacks.
  • Forgetting the secret rotates. Support more than one valid secret during rotation.

Where this fits

Signature verification is one item on a longer list — see webhook security best practices for the full checklist, and how to test webhooks to capture real signed payloads to test against.

Need to verify a signature right now? Open the HMAC verifier, or inspect a live webhook to grab a real signature header.