<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Piyush Anand</title>
    <description>The latest articles on DEV Community by Piyush Anand (@creatorpiyush).</description>
    <link>https://dev.to/creatorpiyush</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F738407%2F2ee69e43-d81e-4ced-b744-d1223a7cc039.jpeg</url>
      <title>DEV Community: Piyush Anand</title>
      <link>https://dev.to/creatorpiyush</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/creatorpiyush"/>
    <language>en</language>
    <item>
      <title>Why your webhook signature check fails (and the bugs that pass it)</title>
      <dc:creator>Piyush Anand</dc:creator>
      <pubDate>Sat, 03 Oct 2026 13:07:05 +0000</pubDate>
      <link>https://dev.to/creatorpiyush/why-your-webhook-signature-check-fails-and-the-bugs-that-pass-it-10fo</link>
      <guid>https://dev.to/creatorpiyush/why-your-webhook-signature-check-fails-and-the-bugs-that-pass-it-10fo</guid>
      <description>&lt;p&gt;In July I wrote about &lt;a href="https://medium.com/@creatorpiyush/i-got-tired-of-copy-pasting-hmac-code-for-every-webhook-provider-so-i-built-verihook-f6355f4c314d" rel="noopener noreferrer"&gt;why I built verihook&lt;/a&gt;: every provider signs webhooks differently, and I was tired of maintaining five slightly different HMAC functions. Since then &lt;a href="https://github.com/creatorpiyush/verihook" rel="noopener noreferrer"&gt;verihook&lt;/a&gt; has grown to 40+ providers, adapters for ten frameworks, testing helpers and a &lt;a href="https://creatorpiyush.github.io/verihook/" rel="noopener noreferrer"&gt;docs site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;pass&lt;/em&gt; when it shouldn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The body parser ate your signature
&lt;/h2&gt;

&lt;p&gt;This is the most common failure by far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// not the bytes Stripe signed&lt;/span&gt;
  &lt;span class="c1"&gt;// signature check fails with the right secret&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;verihook spots this case. When the body's size doesn't match &lt;code&gt;content-length&lt;/code&gt;, the result says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// result.code: "INVALID_SIGNATURE"&lt;/span&gt;
&lt;span class="c1"&gt;// result.hint: "The body is 412 bytes but content-length is 431: it was modified&lt;/span&gt;
&lt;span class="c1"&gt;//   before verification, usually parsed as JSON and re-serialized. ..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The wrong secret (that looks right)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Stripe API key (&lt;code&gt;sk_...&lt;/code&gt;) instead of the endpoint's signing secret (&lt;code&gt;whsec_...&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The test-mode secret in production.&lt;/li&gt;
&lt;li&gt;A Slack bot token instead of the signing secret.&lt;/li&gt;
&lt;li&gt;A trailing newline or quotes from the &lt;code&gt;.env&lt;/code&gt; file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The proxy changed the URL
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  4. Verification passes, but you processed the event twice
&lt;/h2&gt;

&lt;p&gt;Providers deliver &lt;em&gt;at least once&lt;/em&gt;. 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 &lt;code&gt;x-github-delivery&lt;/code&gt;, 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dedupeStore&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DUPLICATE_EVENT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// stop the retries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Your tests pass because they test themselves
&lt;/h2&gt;

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

&lt;p&gt;A test that signs with your code and verifies with your code proves the two agree, not that either matches the provider. What helps:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js App Router&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createWebhookHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verihook/next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createWebhookHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// result.event is typed&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;code&gt;verihook/stripe&lt;/code&gt;) costs about 4 kB. For tests, &lt;code&gt;signWebhook()&lt;/code&gt; builds signed requests for every provider, and &lt;code&gt;npx verihook simulate stripe&lt;/code&gt; sends one to your local server.&lt;/p&gt;

&lt;p&gt;The docs have a page per provider with where to find the secret in each dashboard: &lt;a href="https://creatorpiyush.github.io/verihook/" rel="noopener noreferrer"&gt;creatorpiyush.github.io/verihook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If a provider you use is missing, or a signature scheme looks wrong, I'd like to hear about it. &lt;a href="https://github.com/creatorpiyush/verihook/issues" rel="noopener noreferrer"&gt;Issues&lt;/a&gt; are open.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>typescript</category>
      <category>security</category>
    </item>
  </channel>
</rss>
