<?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: Boris Stiner</title>
    <description>The latest articles on DEV Community by Boris Stiner (@boris-stiner).</description>
    <link>https://dev.to/boris-stiner</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%2F4039473%2F506e79f5-d2e5-40a2-aa1f-1d14cf0dde25.png</url>
      <title>DEV Community: Boris Stiner</title>
      <link>https://dev.to/boris-stiner</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/boris-stiner"/>
    <language>en</language>
    <item>
      <title>Signing webhook payloads in Laravel - and the mistake that breaks it silently</title>
      <dc:creator>Boris Stiner</dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:56:13 +0000</pubDate>
      <link>https://dev.to/boris-stiner/signing-webhook-payloads-in-laravel-and-the-mistake-that-breaks-it-silently-3o82</link>
      <guid>https://dev.to/boris-stiner/signing-webhook-payloads-in-laravel-and-the-mistake-that-breaks-it-silently-3o82</guid>
      <description>&lt;p&gt;Hi, I'm Boris a PHP developer whose deepest experience is TYPO3, with Laravel and Filament being a newer, growing focus. A lot of that work across both has been building admin tooling and integrations that quietly need to just work: payments, webhooks, notifications. This post is about one small piece of that: signing webhook payloads correctly, and a mistake that's easy to make even once you already know HMAC signing in theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;If your app sends webhooks to another service - or receives them - you eventually need the receiver to be able to trust that a payload really came from you and wasn't tampered with in transit. The standard answer is HMAC signing: hash the payload with a shared secret, send the hash alongside the request, and have the receiver recompute it and compare.&lt;/p&gt;

&lt;p&gt;Sounds simple. It is simple until you sign the wrong bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing correctly
&lt;/h2&gt;

&lt;p&gt;The basic recipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send &lt;code&gt;$signature&lt;/code&gt; in a header (e.g. &lt;code&gt;X-Signature&lt;/code&gt;), and the receiver recomputes the HMAC over the body it received and compares. That part almost everyone gets right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: sign the exact bytes that go over the wire
&lt;/h2&gt;

&lt;p&gt;Here's the version that looks correct but isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks fine, but signs and sends two different serializations&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'event'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'order.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;42.00&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// this re-serializes $payload internally&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug: you called &lt;code&gt;json_encode($payload)&lt;/code&gt; once, by hand, to compute the signature - and then handed the &lt;em&gt;array&lt;/em&gt; to Laravel's HTTP client, which JSON-encodes it &lt;em&gt;again&lt;/em&gt;, internally, when it builds the actual request body. That's two separate serialization passes. Most of the time they produce identical bytes, so this works in testing and even in production for a long while. Then one day it doesn't - a float gets formatted differently, or some encoding option differs and the signature silently stops matching. It's an infuriating bug to track down because nothing throws an error; the receiver just starts rejecting your webhooks with no obvious cause.&lt;/p&gt;

&lt;p&gt;The fix is to serialize once and send that exact string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Serialize once, sign that string, send that exact string&lt;/span&gt;
&lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;withBody()&lt;/code&gt; sends the raw string as-is, bypassing the client's own array-to-JSON step entirely. Now the bytes you signed and the bytes you sent are guaranteed to be the same bytes, by construction, not by coincidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying on the receiving end
&lt;/h2&gt;

&lt;p&gt;The exact-bytes principle applies just as much on the receiving side, and it's just as easy to get wrong there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Signature'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&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;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$request-&amp;gt;getContent()&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;$request-&amp;gt;all()&lt;/code&gt; or &lt;code&gt;json_encode($request-&amp;gt;json()-&amp;gt;all())&lt;/code&gt;. The moment you decode the JSON and re-encode it for "readability," you've reintroduced the exact same bug on the receiving side - you're now comparing against a re-serialized version instead of the raw bytes that were actually signed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hash_equals()&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;===&lt;/code&gt;. A plain string comparison short-circuits on the first mismatched byte, which leaks timing information an attacker could in theory use to guess the correct signature one byte at a time. &lt;code&gt;hash_equals()&lt;/code&gt; runs in constant time regardless of where the strings diverge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this came from
&lt;/h2&gt;

&lt;p&gt;I ended up writing this up properly while building the webhook channel for &lt;a href="https://github.com/stboris/filament-outbox" rel="noopener noreferrer"&gt;Filament Outbox&lt;/a&gt; - a small Laravel package with Discord, Slack, Microsoft Teams, and signed-webhook notification channels, built on Laravel's native Notification system (write a normal &lt;code&gt;Notification&lt;/code&gt; class, no new APIs to learn). There's an optional &lt;a href="https://filamentoutbox.com" rel="noopener noreferrer"&gt;Filament v5 admin panel&lt;/a&gt; on top for managing endpoints, browsing send history, and retrying failures - but the free package works standalone in any Laravel app, no Filament required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you're signing webhooks anywhere in your stack, it's worth a quick audit for this exact pattern - a hand-rolled &lt;code&gt;json_encode()&lt;/code&gt; followed by handing the array to an HTTP client is an easy thing to write without noticing the double serialization. Curious if others have hit this one, or have a cleaner pattern for it - let me know in the comments.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webhooks</category>
      <category>filament</category>
    </item>
  </channel>
</rss>
