EngineeringJuly 30, 20267 min read

Your Webhook Signature Tests Prove Nothing

If your test builds the signature with the same formula your verifier checks, both can be wrong together and stay green. How to cross-check webhook verification against the provider's own SDK, and the real bypass we found the same day we started.

Here is a webhook signature test that passes, looks responsible, and proves almost nothing:

const ts  = Math.floor(Date.now() / 1000).toString();
const sig = await hmacHex(`${ts}.${body}`, secret, "SHA-256");
const headers = new Headers({ "stripe-signature": `t=${ts},v1=${sig}` });

expect(await verifySignature("stripe", headers, body, secret)).toBe(true);

The fixture is built with the same formula the verifier checks. If you misread Stripe's docs when you wrote the verifier (signed the wrong string, joined with the wrong delimiter, hex where it should be base64), your test encodes the same misreading and both stay green. You haven't tested "we verify Stripe signatures." You've tested "we agree with ourselves."

We had 70 tests exactly like this across 20 providers. Every one of them passed. Then we asked the only question that matters: would a signature produced by the provider's own code pass our verifier?

Cross-check against the provider's SDK

Some providers ship a signing routine in their official SDK, usually for exactly this purpose. Stripe's is generateTestHeaderString; Svix's Webhook class has .sign(). If those are your dependencies already, cross-checking costs nothing:

import Stripe from "stripe";
import { Webhook } from "svix";
import { verifySignature } from "./index";

it("accepts a header generated by stripe-node", async () => {
  const stripe  = new Stripe("sk_test_" + "0".repeat(24));
  const payload = JSON.stringify({ id: "evt_1", type: "charge.succeeded" });
  const secret  = "whsec_" + "s".repeat(32);

  const header = stripe.webhooks.generateTestHeaderString({ payload, secret });

  expect(
    await verifySignature("stripe", new Headers({ "stripe-signature": header }), payload, secret)
  ).toBe(true);
});

it("accepts a signature generated by the svix SDK", async () => {
  const secret  = "whsec_" + Buffer.from("k".repeat(24)).toString("base64");
  const payload = JSON.stringify({ type: "email.received" });
  const msgId   = "msg_crosscheck";
  const ts      = new Date();

  const signature = new Webhook(secret).sign(msgId, ts, payload);
  const headers = new Headers({
    "svix-id": msgId,
    "svix-timestamp": Math.floor(ts.getTime() / 1000).toString(),
    "svix-signature": signature,
  });

  expect(await verifySignature("svix", headers, payload, secret)).toBe(true);
});

Now the assertion is that we agree with Stripe, not that we agree with us. Add the negative case (sign a payload, then verify a different body) so you know the test can actually fail:

const header   = stripe.webhooks.generateTestHeaderString({ payload, secret });
const tampered = JSON.stringify({ id: "evt_1", amount: 999999 });

expect(await verifySignature("stripe", headers, tampered, secret)).toBe(false);

One wrinkle worth knowing: stripe-node's TypeScript types mark every option of generateTestHeaderString as required, including signature itself, while the implementation derives the signature and defaults the rest. Passing signature: "" to satisfy the compiler makes it sign nothing. Narrow the cast in one place and move on.

Because Clerk and Resend both deliver through Svix infrastructure, one svix cross-check covers three providers. Four of our twenty verifiers now have SDK-backed proof, and the same afternoon's work applies to any stack.

What about providers with no public signer?

GitHub, Shopify, Slack, Discord, PayPal: most providers don't ship a signing routine. For those, the only honest evidence is a captured real delivery. Point a webhook at an endpoint you control, record one signed request, and freeze the (headers, body, secret) triple as a fixture. A replayed real signature is the ground truth no self-built fixture can be.

Until you've done that, be precise with yourself about what your tests show. We keep the ledger in a comment at the bottom of the test file: which providers are SDK-verified, which are self-consistent only, which have no positive test at all. An honest list you can act on beats a green suite you can't trust.

The bug this mindset found the same day

The same suspicion applies beyond unit tests. While verifying our Resend path end-to-end against production (not the unit suite, the real deployed thing), we sent three requests to an endpoint with a signing secret configured:

RequestExpectedGot
Correct signature200200
Wrong signature401401
No signature at all401200

An unsigned request sailed through an endpoint that looked protected. The cause was a policy gap, not a crypto bug. Apps with source generic skip verification by design ("I just want a URL"), and configuring a secret didn't move the app out of generic. The secret sat there verifying nothing, while the API returned success for storing it.

No amount of self-consistent unit testing finds that, because every unit was correct. The fix was to refuse the misleading configuration outright: setting a signing secret on a generic app is now a 400 that tells you which field to set instead.

The checklist

  1. For every provider whose SDK ships a signer: cross-check, both directions (valid passes, tampered fails)
  2. For the rest: capture one real signed delivery and freeze it as a fixture
  3. Keep a visible ledger of which providers have which level of proof
  4. Test the deployed system with the absence of a signature, not just wrong ones. The bypasses live in policy, not math
All postsJuly 30, 2026 · 7 min

Stop losing webhooks.

Change one URL. Get retries, event log, and one-click replay.