<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Creatorpiyush's Dev Blog]]></title><description><![CDATA[Creatorpiyush's Dev Blog]]></description><link>https://creatorpiyush.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 10 Oct 2026 15:36:59 GMT</lastBuildDate><atom:link href="https://creatorpiyush.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why your webhook signature check fails (and the bugs that pass it)]]></title><description><![CDATA[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+ provi]]></description><link>https://creatorpiyush.hashnode.dev/why-your-webhook-signature-check-fails-and-the-bugs-that-pass-it</link><guid isPermaLink="true">https://creatorpiyush.hashnode.dev/why-your-webhook-signature-check-fails-and-the-bugs-that-pass-it</guid><category><![CDATA[webdev]]></category><category><![CDATA[node]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Piyush Anand]]></dc:creator><pubDate>Sat, 03 Oct 2026 13:15:43 GMT</pubDate><content:encoded><![CDATA[<p>In July I wrote about <a href="https://medium.com/@creatorpiyush/i-got-tired-of-copy-pasting-hmac-code-for-every-webhook-provider-so-i-built-verihook-f6355f4c314d">why I built verihook</a>: every provider signs webhooks differently, and I was tired of maintaining five slightly different HMAC functions. Since then <a href="https://github.com/creatorpiyush/verihook">verihook</a> has grown to 40+ providers, adapters for ten frameworks, testing helpers and a <a href="https://creatorpiyush.github.io/verihook/">docs site</a>.</p>
<p>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 <em>pass</em> when it shouldn't.</p>
<h2>1. The body parser ate your signature</h2>
<p>This is the most common failure by far:</p>
<pre><code class="language-ts">app.use(express.json());

app.post('/webhooks/stripe', (req, res) =&gt; {
  const payload = JSON.stringify(req.body); // not the bytes Stripe signed
  // signature check fails with the right secret
});
</code></pre>
<p>The provider signed the exact bytes it sent. <code>JSON.stringify(JSON.parse(body))</code> changes whitespace, escapes (<code>é</code> vs <code>é</code>) and number formatting. The fix is to verify the <strong>raw</strong> body before any parser runs: <code>express.raw()</code>, <code>await request.text()</code>, Fastify's <code>rawBody</code>, NestJS's <code>rawBody: true</code>.</p>
<p>verihook spots this case. When the body's size doesn't match <code>content-length</code>, the result says so:</p>
<pre><code class="language-ts">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. ..."
</code></pre>
<h2>2. The wrong secret (that looks right)</h2>
<ul>
<li><p>A Stripe API key (<code>sk_...</code>) instead of the endpoint's signing secret (<code>whsec_...</code>).</p>
</li>
<li><p>The test-mode secret in production.</p>
</li>
<li><p>A Slack bot token instead of the signing secret.</p>
</li>
<li><p>A trailing newline or quotes from the <code>.env</code> file.</p>
</li>
</ul>
<p>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.</p>
<h2>3. The proxy changed the URL</h2>
<p>Twilio, Square and HubSpot sign the public URL they called. Behind ngrok, a load balancer or API Gateway, your server sees <code>http://10.0.0.5:3000/...</code> instead of <code>https://api.example.com/...</code>, and verification fails. Rebuild the URL from <code>x-forwarded-proto</code> and <code>x-forwarded-host</code>, or pass the public URL explicitly.</p>
<h2>4. Verification passes, but you processed the event twice</h2>
<p>Providers deliver <em>at least once</em>. 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 <code>x-github-delivery</code>, 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.</p>
<pre><code class="language-ts">const result = await verifyWebhook('stripe', req, secret, { dedupeStore });
if (result.code === 'DUPLICATE_EVENT') return res.status(200).end(); // stop the retries
</code></pre>
<h2>5. Your tests pass because they test themselves</h2>
<p>This one bit me. verihook's Paddle verifier read <code>h=</code> from the <code>Paddle-Signature</code> header. Its test signer also wrote <code>h=</code>. Every test passed. Paddle actually sends <code>h1=</code>, so every real Paddle webhook was rejected.</p>
<p>A test that signs with your code and verifies with your code proves the two agree, not that either matches the provider. What helps:</p>
<ul>
<li><p><strong>Known-good vectors</strong> from the provider's docs: a payload, secret and signature you didn't compute yourself.</p>
</li>
<li><p><strong>Conformance tests against official SDKs.</strong> verihook's CI signs with the official Stripe, Octokit, Svix and Twilio SDKs and verifies with verihook, and the other way around.</p>
</li>
</ul>
<h2>What it looks like</h2>
<pre><code class="language-ts">// Next.js App Router
import { createWebhookHandler } from 'verihook/next';

export const POST = createWebhookHandler('stripe', process.env.STRIPE_WEBHOOK_SECRET!, async (payload, result) =&gt; {
  if (result.eventType === 'checkout.session.completed') {
    // result.event is typed
  }
});
</code></pre>
<p>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 (<code>verihook/stripe</code>) costs about 4 kB. For tests, <code>signWebhook()</code> builds signed requests for every provider, and <code>npx verihook simulate stripe</code> sends one to your local server.</p>
<p>The docs have a page per provider with where to find the secret in each dashboard: <a href="https://creatorpiyush.github.io/verihook/">creatorpiyush.github.io/verihook</a>.</p>
<p>If a provider you use is missing, or a signature scheme looks wrong, I'd like to hear about it. <a href="https://github.com/creatorpiyush/verihook/issues">Issues</a> are open.</p>
]]></content:encoded></item></channel></rss>