<?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: Vladimir Vinkurov</title>
    <description>The latest articles on DEV Community by Vladimir Vinkurov (@vinkurov_live).</description>
    <link>https://dev.to/vinkurov_live</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%2F4068864%2Fe908b7d4-fda9-42b6-b4f3-8365c6a39c44.png</url>
      <title>DEV Community: Vladimir Vinkurov</title>
      <link>https://dev.to/vinkurov_live</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vinkurov_live"/>
    <language>en</language>
    <item>
      <title>Eight webhook providers, eight different ways to sign a request</title>
      <dc:creator>Vladimir Vinkurov</dc:creator>
      <pubDate>Sat, 08 Aug 2026 13:28:09 +0000</pubDate>
      <link>https://dev.to/vinkurov_live/eight-webhook-providers-eight-different-ways-to-sign-a-request-3a26</link>
      <guid>https://dev.to/vinkurov_live/eight-webhook-providers-eight-different-ways-to-sign-a-request-3a26</guid>
      <description>&lt;p&gt;I integrated Stripe, then Shopify, then Slack. Each time I wrote webhook signature&lt;br&gt;
verification from scratch, because nothing from the last one carried over. That&lt;br&gt;
got old, so I sat down and read the actual specifications for eight providers to&lt;br&gt;
find out how much they really differ.&lt;/p&gt;

&lt;p&gt;More than I expected. Here is the map, and the four places where the differences&lt;br&gt;
are worth knowing about rather than just annoying.&lt;/p&gt;
&lt;h2&gt;
  
  
  The map
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Header(s)&lt;/th&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;th&gt;Timestamp&lt;/th&gt;
&lt;th&gt;Delivery id&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Stripe-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;300s, stale only&lt;/td&gt;
&lt;td&gt;body &lt;code&gt;id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Hub-Signature-256&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-GitHub-Delivery&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Shopify-Hmac-SHA256&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;base64&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Slack-Signature&lt;/code&gt; + &lt;code&gt;X-Slack-Request-Timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;300s, both ways&lt;/td&gt;
&lt;td&gt;body &lt;code&gt;event_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard Webhooks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;webhook-id&lt;/code&gt; / &lt;code&gt;-timestamp&lt;/code&gt; / &lt;code&gt;-signature&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;base64&lt;/td&gt;
&lt;td&gt;300s, both ways&lt;/td&gt;
&lt;td&gt;&lt;code&gt;webhook-id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paddle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Paddle-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256&lt;/td&gt;
&lt;td&gt;hex&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;5s&lt;/strong&gt;, stale only&lt;/td&gt;
&lt;td&gt;body &lt;code&gt;event_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Twilio-Signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HMAC-&lt;strong&gt;SHA1&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;base64&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;body &lt;code&gt;MessageSid&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-Telegram-Bot-Api-Secret-Token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none — shared secret&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;body &lt;code&gt;update_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even the part everyone agrees on — HMAC-SHA256 over something — hides four&lt;br&gt;
different "somethings":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe             &amp;lt;timestamp&amp;gt;.&amp;lt;body&amp;gt;
Slack              v0:&amp;lt;timestamp&amp;gt;:&amp;lt;body&amp;gt;
Paddle             &amp;lt;timestamp&amp;gt;:&amp;lt;body&amp;gt;
Standard Webhooks  &amp;lt;id&amp;gt;.&amp;lt;timestamp&amp;gt;.&amp;lt;body&amp;gt;
GitHub, Shopify    &amp;lt;body&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note Stripe and Paddle. Same algorithm, same encoding, same two ingredients —&lt;br&gt;
different delimiter. Copy a working Stripe verifier over to Paddle and change the&lt;br&gt;
header name, and you get something that runs, throws no errors, and rejects every&lt;br&gt;
genuine delivery.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. The same secret prefix means two different things
&lt;/h2&gt;

&lt;p&gt;Stripe's signing secret looks like &lt;code&gt;whsec_abc123...&lt;/code&gt;. Standard Webhooks' signing&lt;br&gt;
secret also looks like &lt;code&gt;whsec_abc123...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They are not used the same way:&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="c1"&gt;// Stripe: the entire string is the HMAC key, prefix included.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripeKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whsec_abc123...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Standard Webhooks: base64-decode everything after the prefix.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;swKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abc123...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is nastier than a wrong delimiter, because the failure is silent in the&lt;br&gt;
worst direction. If you write the Standard Webhooks verifier using Stripe's&lt;br&gt;
convention, you compute an HMAC under the wrong key, no signature ever matches,&lt;br&gt;
and you "fix" it by loosening the check. I have seen exactly that fix in the wild:&lt;br&gt;
a &lt;code&gt;try/catch&lt;/code&gt; around verification that logs and continues.&lt;/p&gt;

&lt;p&gt;Standard Webhooks is the scheme behind Svix, Clerk and Resend, so this affects&lt;br&gt;
more services than the name suggests.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Twilio doesn't sign the body
&lt;/h2&gt;

&lt;p&gt;Twilio is the outlier twice over. It uses SHA-1 rather than SHA-256, and it signs&lt;br&gt;
the &lt;strong&gt;request URL&lt;/strong&gt; rather than the payload.&lt;/p&gt;

&lt;p&gt;For form-encoded deliveries, it appends every parameter's name and value to the&lt;br&gt;
URL, sorted by name, and signs that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/hookCallSidCA123Digits1234
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For JSON deliveries, it signs the URL alone. The body is covered indirectly: the&lt;br&gt;
URL is expected to carry a &lt;code&gt;bodySHA256&lt;/code&gt; query parameter holding the digest of the&lt;br&gt;
body.&lt;/p&gt;

&lt;p&gt;Which means: &lt;strong&gt;if you verify the signature but don't check that &lt;code&gt;bodySHA256&lt;/code&gt;&lt;br&gt;
actually matches the body you received, the body is not protected at all.&lt;/strong&gt; The&lt;br&gt;
signature proves the URL is genuine. The payload is free to be anything.&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="c1"&gt;// Necessary, not optional, for Twilio's JSON mode.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bodySHA256&lt;/span&gt;&lt;span class="dl"&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;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bytesToHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A verifier that skips this is a verifier that authenticates nothing about the&lt;br&gt;
content — while looking, in the logs, exactly like one that works.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Nobody agrees on what a timestamp means
&lt;/h2&gt;

&lt;p&gt;Four of the eight schemes carry a timestamp so you can reject replayed requests.&lt;br&gt;
They disagree on the window and on the direction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt;: 300 seconds, and only rejects deliveries that are too &lt;em&gt;old&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt;: 300 seconds, absolute value — too old &lt;em&gt;or&lt;/em&gt; too far in the future.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard Webhooks&lt;/strong&gt;: same, both directions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle&lt;/strong&gt;: &lt;strong&gt;5 seconds&lt;/strong&gt;, stale only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tidy thing to do is pick 300 seconds in both directions and apply it&lt;br&gt;
everywhere. I started there and it was wrong in two different directions at once.&lt;/p&gt;

&lt;p&gt;Making Stripe two-sided breaks real deliveries. If Stripe's clock is a few seconds&lt;br&gt;
ahead of yours, a genuine webhook arrives "from the future" and you reject it — for&lt;br&gt;
no security benefit, because the timestamp is covered by the signature. An attacker&lt;br&gt;
cannot choose it. Only Stripe can, and Stripe has no reason to lie to you.&lt;/p&gt;

&lt;p&gt;Relaxing Paddle to 300 seconds silently widens your replay window by 60× compared&lt;br&gt;
to Paddle's own SDK. If someone migrates to your library expecting parity, you have&lt;br&gt;
weakened their setup without telling them.&lt;/p&gt;

&lt;p&gt;So: implement each vendor's rule, and document it. Paddle's 5 seconds is genuinely&lt;br&gt;
tight enough to reject real traffic under ordinary clock drift — that is Paddle's&lt;br&gt;
choice to make, and the caller's choice to override, not the library's choice to&lt;br&gt;
quietly paper over.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. The raw body trap
&lt;/h2&gt;

&lt;p&gt;This is the one that costs the most debugging hours, and it has nothing to do with&lt;br&gt;
which provider you use.&lt;/p&gt;

&lt;p&gt;A signature covers the exact bytes that were sent. Your framework, being helpful,&lt;br&gt;
parses those bytes into an object before your handler runs — and the bytes are then&lt;br&gt;
gone. Re-serializing gives you &lt;em&gt;a&lt;/em&gt; JSON document, not &lt;em&gt;the&lt;/em&gt; one:&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;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"amount":1000,  "currency":"usd"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// '{"amount":1000,"currency":"usd"}'  ← two spaces gone, signature now invalid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key order, whitespace, unicode escaping and number formatting are all free to&lt;br&gt;
change. This is not a bug in &lt;code&gt;JSON.parse&lt;/code&gt;; it simply is not a lossless round trip.&lt;/p&gt;

&lt;p&gt;What makes it vicious is that it fails &lt;em&gt;inconsistently&lt;/em&gt;. Payloads with no&lt;br&gt;
incidental whitespace round-trip fine, so verification often works against your own&lt;br&gt;
fixtures and fails against the provider's real deliveries.&lt;/p&gt;

&lt;p&gt;In Express the fix is entirely about middleware order:&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="c1"&gt;// ✅ Raw parser on the webhook route, mounted before any JSON parser exists.&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="s2"&gt;/hooks/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;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;handler&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;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="c1"&gt;// everything else, after&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Silently breaks every webhook route below it.&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;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="s2"&gt;/hooks/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;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;type: "*/*"&lt;/code&gt; rather than &lt;code&gt;"application/json"&lt;/code&gt;, because GitHub and Slack can&lt;br&gt;
both deliver form-encoded bodies, and a mismatched &lt;code&gt;type&lt;/code&gt; hands you an empty object&lt;br&gt;
instead of a Buffer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two things that bit me while implementing
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Joining strings corrupts bodies that aren't valid UTF-8
&lt;/h3&gt;

&lt;p&gt;The schemes that prefix the body with a timestamp are specified over &lt;em&gt;bytes&lt;/em&gt;. The&lt;br&gt;
obvious implementation is a template literal:&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;signedPayload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// works, until it doesn't&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;body&lt;/code&gt; came off the wire as a string that is already valid UTF-8, fine. If it&lt;br&gt;
contains malformed UTF-8, the round trip through JavaScript's UTF-16 strings&lt;br&gt;
replaces those bytes with U+FFFD, and the signature cannot match. Join the bytes&lt;br&gt;
instead:&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;signedPayload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bodyBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; is unusable on edge runtimes
&lt;/h3&gt;

&lt;p&gt;Comparing signatures with &lt;code&gt;===&lt;/code&gt; is a real vulnerability, not a style issue. String&lt;br&gt;
equality returns on the first differing byte, so response timing leaks how many&lt;br&gt;
leading bytes matched — which turns forging a signature from a 2^256 problem into&lt;br&gt;
roughly 32 × 256 guesses.&lt;/p&gt;

&lt;p&gt;Node's answer is &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;. It has two problems here. It doesn't&lt;br&gt;
exist in Web Crypto, so it is unavailable on Cloudflare Workers, Vercel Edge, Deno&lt;br&gt;
and browsers. And it &lt;strong&gt;throws&lt;/strong&gt; when the two inputs differ in length — which is&lt;br&gt;
exactly what a truncated attacker-supplied signature produces, so you need a length&lt;br&gt;
check first, and that check leaks the length.&lt;/p&gt;

&lt;p&gt;What works on every runtime is double-HMAC blinding: generate a random key for the&lt;br&gt;
single comparison, HMAC both operands under it, and compare the fixed-length&lt;br&gt;
digests without an early exit.&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;diff&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the key is unpredictable and used once, an attacker cannot steer the digest&lt;br&gt;
bytes, so timing reveals nothing about the originals. It is also length-independent&lt;br&gt;
for free: digests are always 32 bytes, so mismatched inputs need no special case.&lt;/p&gt;

&lt;p&gt;To be clear about what that does and does not establish: the property is&lt;br&gt;
structural — no data-dependent branches, no early exit, blinded operands. I have&lt;br&gt;
not validated it with statistical timing measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you only remember three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture the raw bytes&lt;/strong&gt; before anything parses them. Most verification
failures are this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never compare signatures with &lt;code&gt;===&lt;/code&gt;.&lt;/strong&gt; Use a constant-time comparison, and on
edge runtimes that means writing one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow each provider's timestamp rule&lt;/strong&gt; rather than inventing a uniform one.
Uniformity breaks Stripe in one direction and weakens Paddle in the other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I ended up packaging all of this so I would not write it a ninth time:&lt;br&gt;
&lt;a href="https://github.com/vinkurov/webhook-kit" rel="noopener noreferrer"&gt;webhook-kit&lt;/a&gt; — one API over the eight&lt;br&gt;
schemes, zero runtime dependencies, Web Crypto only so it runs on Node, Bun, Deno&lt;br&gt;
and edge runtimes.&lt;/p&gt;

&lt;p&gt;It is new, so there is no adoption to point at. If you spot something wrong in the&lt;br&gt;
crypto or in my reading of a spec, I would genuinely like to know.&lt;/p&gt;

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