Test Webhooks Locally and Verify HMAC Signatures

Building a webhook receiver locally has two problems that most explainer articles skip. First, the provider (Stripe, GitHub, Shopify, etc.) lives on the public internet and cannot reach http://localhost:3000. Second, once the request finally arrives, your signature check rejects it even though the secret is correct. This guide solves both: how to expose localhost to a provider, and the top three reasons HMAC verification fails along with exact fixes.

Step 1: Let the provider reach your localhost

A webhook is an outbound HTTP POST from the provider to a URL you registered. Your dev machine has no public address, so you need a tunnel that gives you a temporary public HTTPS URL forwarding to your local port. Tools that do this include ngrok, Cloudflare Tunnel (cloudflared), and the Stripe CLI's built-in forwarder. The workflow is the same regardless of tool:

  1. Start your receiver locally (e.g. listening on port 3000).
  2. Start the tunnel pointed at that port. It prints a public URL like https://abc123.ngrok-free.app.
  3. Paste that URL (plus your route, e.g. /webhooks/stripe) into the provider's webhook settings, or pass it to the provider CLI.
  4. Trigger a test event from the provider dashboard or CLI and watch it hit your handler.

One gotcha: many tunnels rotate the public URL each restart, so you must re-register it with the provider every session unless you have a reserved domain. While iterating on parsing logic without a live provider, you can shape realistic payloads with the API mock generator and POST them to your endpoint with curl, so you are not burning real test events.

Step 2: Understand the signature you must verify

Providers sign each request so you can prove it came from them and was not tampered with. They compute an HMAC over the request body using a shared secret, then send the result in a header. You recompute the same HMAC and compare. The header name and format differ by provider:

  • GitHub sends X-Hub-Signature-256 as sha256=<hex> (it also sends a legacy SHA-1 X-Hub-Signature; prefer the 256 one).
  • Shopify sends X-Shopify-Hmac-Sha256 as base64.
  • Stripe sends Stripe-Signature using a timestamped scheme: t=<unix_ts>,v1=<hex_hmac>.

The Stripe-style scheme is worth a closer look because it trips people up. Per Stripe's docs, the signed payload is not the raw body alone. You build a string by concatenating the timestamp, a literal period, and the body: signed_payload = t + "." + raw_body. You HMAC that, then compare to the v1 value. Verifying against the body alone will always fail. Because the timestamp is part of the signed string, an attacker cannot alter it without breaking the signature, which lets you reject replays by checking the timestamp is recent (Stripe's libraries default to a 5-minute tolerance).

The top 3 reasons HMAC verification fails

1. You hashed the parsed body, not the raw bytes

This is the most common cause. The provider signs the exact bytes it sent over the wire. If a body parser (such as Express's express.json()) runs first, it consumes the stream and hands you a JavaScript object. Re-serializing that object with JSON.stringify() almost never reproduces the original bytes: key order, whitespace, and Unicode escaping can all differ, so your HMAC differs too. The fix is to capture the raw body before any parsing. In Express, supply a verify callback to stash req.rawBody = buf, or mount express.raw({ type: 'application/json' }) on the webhook route only. In frameworks like Next.js route handlers, read await req.text() first and parse the JSON yourself afterward. Always sign the raw string or buffer.

2. You compared with === instead of a timing-safe function

A naive actual === expected string compare leaks information through how long it takes to fail, which enables timing attacks. Use a constant-time comparison: crypto.timingSafeEqual() in Node.js and hmac.compare_digest() in Python. Both require equal-length inputs, so compare the decoded bytes (or guard the length first) rather than passing mismatched-length strings, which can throw.

3. Encoding, prefix, or secret mismatch

Decide whether the provider's signature is hex or base64 and produce the same. Strip any prefix such as sha256= before comparing (or include it on both sides). Make sure you are using the webhook signing secret (Stripe's begins with whsec_ and is scoped per endpoint and per mode), not an API key, and that there is no trailing newline or quote from copy-paste. Hashing a different algorithm (SHA-1 vs SHA-256) than the provider expects also produces a clean-looking but wrong digest.

Verify in Node.js and Python

Node.js, GitHub-style header:

const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)        // rawBody is a Buffer/string, not the parsed object
    .digest('hex');
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python, base64-style header:

import hmac, hashlib, base64

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    return hmac.compare_digest(expected, header)

Debug a failing signature by hand

When verification keeps failing, isolate the variables with the on-site HMAC generator. Paste the exact raw request body into the message field, paste your signing secret into the secret field, and choose SHA-256. To check a provider's signature, switch the tool to Verify mode and drop the signature (with any sha256= prefix removed) into the field that appears; otherwise read the generated hex or base64 output and compare it yourself. If the tool matches the provider's signature but your code does not, the bug is in your code, usually the raw-body capture in cause 1. If it does not match here either, your input is wrong, usually the secret, the algorithm, or, for Stripe, forgetting the t.body signed payload. For separate token problems you can also inspect bearer tokens with the JWT decoder. For deeper background on the signing model, see what is a webhook signature.

Frequently Asked Questions

Almost always because you hashed the parsed JSON instead of the raw request bytes. Body parsers consume the stream and re-serializing the object changes whitespace, key order, or escaping. Capture the raw body before parsing and HMAC that exact string or buffer.

Run a tunnel such as ngrok, cloudflared, or the Stripe CLI forwarder pointed at your local port. It returns a public HTTPS URL that forwards to your machine. Register that URL plus your route in the provider's webhook settings, then trigger a test event.

Stripe's Stripe-Signature header uses t=,v1=. You must HMAC the string timestamp + "." + raw_body, not the body alone, then compare to v1. The timestamp also lets you reject replayed requests by checking that it is recent.

A normal equality check can return faster on an early-mismatching byte, leaking timing information that helps an attacker guess the signature. Use crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python so comparison time does not depend on where the mismatch occurs.

It varies by provider. GitHub uses X-Hub-Signature-256 (sha256=hex), Shopify uses X-Shopify-Hmac-Sha256 (base64), and Stripe uses Stripe-Signature with its t/v1 format. Always check the provider's docs for the exact header name and encoding before comparing.