DEV Community

Piyush Anand
Piyush Anand

Posted on

Why your webhook signature check fails (and the bugs that pass it)

In July I wrote about why I built verihook: every provider signs webhooks differently, and I was tired of maintaining five slightly different HMAC functions. Since then verihook has grown to 40+ providers, adapters for ten frameworks, testing helpers and a docs site.

Supporting that many providers taught me where webhook verification actually goes wrong. It's rarely the HMAC. It's everything around it: the body, the secret, the URL, retries and tests. Here are the five mistakes I see most, including the worse ones that make verification pass when it shouldn't.

1. The body parser ate your signature

This is the most common failure by far:

app.use(express.json());

app.post('/webhooks/stripe', (req, res) => {
  const payload = JSON.stringify(req.body); // not the bytes Stripe signed
  // signature check fails with the right secret
});
Enter fullscreen mode Exit fullscreen mode

The provider signed the exact bytes it sent. JSON.stringify(JSON.parse(body)) changes whitespace, escapes (é vs é) and number formatting. The fix is to verify the raw body before any parser runs: express.raw(), await request.text(), Fastify's rawBody, NestJS's rawBody: true.

verihook spots this case. When the body's size doesn't match content-length, the result says so:

const result = await verifyWebhook('stripe', req, secret);
// result.code: "INVALID_SIGNATURE"
// result.hint: "The body is 412 bytes but content-length is 431: it was modified
//   before verification, usually parsed as JSON and re-serialized. ..."
Enter fullscreen mode Exit fullscreen mode

2. The wrong secret (that looks right)

  • A Stripe API key (sk_...) instead of the endpoint's signing secret (whsec_...).
  • The test-mode secret in production.
  • A Slack bot token instead of the signing secret.
  • A trailing newline or quotes from the .env file.

All four produce "signature mismatch" with no other clue. verihook recognizes the API key, the whitespace and the quotes from the secret's shape, and its hint names the mistake.

3. The proxy changed the URL

Twilio, Square and HubSpot sign the public URL they called. Behind ngrok, a load balancer or API Gateway, your server sees http://10.0.0.5:3000/... instead of https://api.example.com/..., and verification fails. Rebuild the URL from x-forwarded-proto and x-forwarded-host, or pass the public URL explicitly.

4. Verification passes, but you processed the event twice

Providers deliver at least once. A timeout makes them retry, and a valid signature accepts every copy. You need deduplication, and the key matters: if you key it on a header the signature doesn't cover (GitHub's x-github-delivery, say), an attacker can replay a captured webhook with a fresh header and walk past your dedupe store. verihook only keys on data the signature covers: a signed ID header, an ID inside the signed body, or a hash of the body.

const result = await verifyWebhook('stripe', req, secret, { dedupeStore });
if (result.code === 'DUPLICATE_EVENT') return res.status(200).end(); // stop the retries
Enter fullscreen mode Exit fullscreen mode

5. Your tests pass because they test themselves

This one bit me. verihook's Paddle verifier read h= from the Paddle-Signature header. Its test signer also wrote h=. Every test passed. Paddle actually sends h1=, so every real Paddle webhook was rejected.

A test that signs with your code and verifies with your code proves the two agree, not that either matches the provider. What helps:

  • Known-good vectors from the provider's docs: a payload, secret and signature you didn't compute yourself.
  • Conformance tests against official SDKs. verihook's CI signs with the official Stripe, Octokit, Svix and Twilio SDKs and verifies with verihook, and the other way around.

What it looks like

// Next.js App Router
import { createWebhookHandler } from 'verihook/next';

export const POST = createWebhookHandler('stripe', process.env.STRIPE_WEBHOOK_SECRET!, async (payload, result) => {
  if (result.eventType === 'checkout.session.completed') {
    // result.event is typed
  }
});
Enter fullscreen mode Exit fullscreen mode

There are one-line adapters for Express, Fastify, Hono, NestJS, Nuxt, SvelteKit, Remix, Astro and AWS Lambda. It runs on Node, Deno, Bun and edge runtimes, and importing one provider (verihook/stripe) costs about 4 kB. For tests, signWebhook() builds signed requests for every provider, and npx verihook simulate stripe sends one to your local server.

The docs have a page per provider with where to find the secret in each dashboard: creatorpiyush.github.io/verihook.

If a provider you use is missing, or a signature scheme looks wrong, I'd like to hear about it. Issues are open.

Top comments (1)

Collapse
 
challan116ux profile image
challan116-ux •

The dedupe-key point in #4 is the subtle one — keying on a header the signature doesn't cover turns replay protection into a suggestion, and I've seen the same bug with unsigned delivery IDs in API webhooks. The Paddle h= vs h1= story in #5 is also exactly why we keep a folder of real, captured partner payloads: a signer and verifier written by the same person will happily agree with each other forever.