← All writing

Webhook HMAC signatures: verify the exact bytes you received

PHP 8.3+Sources checked 2026-09-12

A webhook signature check fails even though the decoded payload looks correct. One common cause is decoding JSON and encoding it again before verification. Whitespace and key order can change the bytes while leaving the represented object looking identical.

Make the byte-level difference visible

<?php
$secret = 'local-test-secret';
$raw = '{"sku": "AX-204", "stock": 0}';
$signature = hash_hmac('sha256', $raw, $secret);
$valid = hash_equals(hash_hmac('sha256', $raw, $secret), $signature);
$rebuilt = json_encode(json_decode($raw, true), JSON_THROW_ON_ERROR);
assert($valid);
assert($rebuilt !== $raw);
assert(!hash_equals($signature, hash_hmac('sha256', $rebuilt, $secret)));
echo "raw body verified", PHP_EOL;

This is a local demonstration, not a provider-specific webhook implementation. The provider may sign a timestamp plus body, use base64 rather than hex, or include a signature prefix. Follow its exact specification.

Verify before trusting business fields

In Laravel, read the original body from the request, obtain the configured secret server-side, and compare the expected signature with the submitted signature using hash_equals. Reject malformed or missing headers. Do not log the secret or full authorization header while debugging.

A matching signature establishes that the bytes were signed with the shared secret. It does not prove that the event is new. Record a stable event identifier under a unique database constraint and enforce the provider's timestamp tolerance when available.

Test the failure path

Change one byte, change the secret, omit the signature, and replay a previously processed event. Your endpoint needs defined responses for all four cases. Test replay handling independently from cryptographic verification; a valid replay should not duplicate a shipment or ledger entry.

PHP's hash_equals reference documents timing-safe string comparison. Keep the known computed value as its first argument and user-provided data as the second.