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.
The problem
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.
Sounds simple. It is simple until you sign the wrong bytes.
Signing correctly
The basic recipe:
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, $secret);
Send $signature in a header (e.g. X-Signature), and the receiver recomputes the HMAC over the body it received and compares. That part almost everyone gets right.
The gotcha: sign the exact bytes that go over the wire
Here's the version that looks correct but isn't:
// Looks fine, but signs and sends two different serializations
$payload = ['event' => 'order.created', 'total' => 42.00];
$signature = hash_hmac('sha256', json_encode($payload), $secret);
Http::withHeaders(['X-Signature' => $signature])
->post($url, $payload); // this re-serializes $payload internally
The bug: you called json_encode($payload) once, by hand, to compute the signature - and then handed the array to Laravel's HTTP client, which JSON-encodes it again, 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.
The fix is to serialize once and send that exact string:
// Serialize once, sign that string, send that exact string
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, $secret);
Http::withHeaders(['X-Signature' => $signature])
->withBody($body, 'application/json')
->post($url);
withBody() 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.
Verifying on the receiving end
The exact-bytes principle applies just as much on the receiving side, and it's just as easy to get wrong there:
public function verify(Request $request, string $secret): bool
{
$expected = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expected, $request->header('X-Signature', ''));
}
Two details worth calling out:
-
$request->getContent(), not$request->all()orjson_encode($request->json()->all()). 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. -
hash_equals(), not===. 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.hash_equals()runs in constant time regardless of where the strings diverge.
Where this came from
I ended up writing this up properly while building the webhook channel for Filament Outbox - a small Laravel package with Discord, Slack, Microsoft Teams, and signed-webhook notification channels, built on Laravel's native Notification system (write a normal Notification class, no new APIs to learn). There's an optional Filament v5 admin panel on top for managing endpoints, browsing send history, and retrying failures - but the free package works standalone in any Laravel app, no Filament required.
Wrap-up
If you're signing webhooks anywhere in your stack, it's worth a quick audit for this exact pattern - a hand-rolled json_encode() 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.
Top comments (0)