Your endpoint accepts a webhook, decodes the JSON, re-encodes it to check the signature, and refuses every delivery. The sender signed the bytes it put on the wire, and your framework already parsed and rebuilt them with different key ordering and different whitespace. The payload is identical in meaning and different in bytes.
What you get
You will end up with a verifier that checks the exact bytes received, refuses a stale delivery on the timestamp alone, and accepts either of two secrets during a rotation. This is for you if you receive webhooks in PHP and want the checks written down.
Short answer
Sign and verify over the exact bytes that arrived, joined as id.timestamp.body, with HMAC SHA-256
and a constant-time comparison. Reject a timestamp outside a few minutes before hashing anything.
Hold more than one secret so a rotation overlaps, and accept a header that offers several signatures
at once.
You will need
PHP 8.1 or later, and a sender that signs to the Standard
Webhooks scheme. The three headers are webhook-id,
webhook-timestamp and webhook-signature, and the digest is
HMAC over the joined string rather than over the body
alone.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A gateway that verifies at the edge | Many services receiving from one sender, and a gateway you already run | Secrets held at the edge, and services that trust a header they cannot check | One service, or a sender whose scheme the gateway cannot express |
| A hand-written verifier | You want the checks visible and the dependency count at zero | Forty lines to own, including the parts that are easy to get subtly wrong | A maintained library covers the same scheme |
| A provider SDK verifier | You receive from one provider and use their client already | A verifier per provider, each with its own header names and tolerances | You receive from several senders using one scheme |
| The standardwebhooks PHP library | You want the scheme implemented by the people who wrote it | A dependency, and its choices about tolerance and error reporting | You need to accept two secrets, or to log which one matched |
The scheme is small enough that the choice is not really about effort. What decides it is what you need to know afterwards. A library answers whether the delivery was valid. A verifier you own can also say which secret matched, which is how you learn that a rotation is complete. It can also report why a delivery failed in words your support team can read.
Verify in the service rather than at the edge unless you have a reason. A service that trusts a header saying the gateway checked has moved the security boundary to whatever else can set that header.
Sign the bytes, not the meaning
The signed content is three fields joined, and the body is the raw payload.
public function sign(string $id, int $timestamp, string $payload, string $keyId): string
{
$secret = base64_decode($this->keys[$keyId], true);
$digest = hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", $secret, true);
return 'v1,' . base64_encode($digest);
}
Capture the body before anything parses it. In most PHP setups that means reading php://input
yourself and keeping the string, because a framework that hands you an array has already thrown the
bytes away.1 Some frameworks keep the raw body for you, and it is worth checking which yours does
before writing any of this.
The message id is in the signed content for a reason. Without it, a signature captured from one delivery verifies against another delivery with the same body and timestamp, which is exactly a replay. The timestamp narrows the window and the id closes it. The timestamp narrows the window and the id closes it.
Check the clock before the hash
A stale delivery is refused on the timestamp, and that check is cheap.
$age = $now - (int) $timestamp;
if (abs($age) > $this->toleranceSeconds) {
return ['ok' => false, 'reason' => "timestamp is {$age}s away, tolerance is {$this->toleranceSeconds}s"];
}
Use abs rather than a one-sided comparison. A sender whose clock runs fast produces timestamps in
your future, and a one-sided check accepts them forever.
Compare with hash_equals, known signature first and the header’s value second, and never with
==. A byte-by-byte comparison that returns early leaks
how much of a guess was right, and a signature is exactly the kind of secret that attack is aimed
at.2
Check it worked
Seven deliveries, two of them valid for different reasons.
php demo.php
signed with the current secret ok accepted via whsec_2026
signed with the previous secret ok accepted via whsec_2025
both offered during rotation ok accepted via whsec_2026
body altered after signing refused no signature matched a known secret
message id swapped refused no signature matched a known secret
replayed an hour later refused timestamp is 3600s away, tolerance is 300s
unsigned request refused no signature matched a known secret
The third line is the rotation working. The sender offers both signatures in one header while it migrates, and the receiver accepts on the newer secret and says so. Reading which key matched is how you know when the old secret can be removed, rather than guessing from a date.
The last four lines are four different refusals and only one of them is a clock problem. Keeping them apart matters during an incident. A body that was altered and a body that your own framework re-encoded produce the same digest mismatch. Only the second one is your bug, and it is the likelier of the two.
php -d zend.assertions=1 test.php
ok a signature from the current secret is accepted
ok the previous secret still verifies during rotation
ok one changed byte in the body refuses the message
ok a re-encoded body with the same meaning is refused
ok a replay outside the tolerance is refused on the timestamp alone
ok a non-numeric timestamp is refused before any hashing
6 cases, 0 failures
When it goes wrong
Every delivery is refused and the secret is right. The body was parsed and rebuilt before verification. Read the raw input, verify, and parse afterwards.
Deliveries fail at certain times of day. Clock skew between the sender and your host. Run a time daemon on the receiving host, and widen the tolerance only after you have measured the skew rather than guessed at it.
A rotation drops deliveries. The receiver holds one secret. Accept a list, add the new secret before the sender switches, and remove the old one after the logs show nothing matching it.
Verification passes and the handler runs twice. A signature check is not a duplicate check. Store the message id and reject one you have already processed. Senders retry on any failure, including a timeout after your handler succeeded.
When not to do this
Do not write your own scheme when the sender implements a published one. A custom header order and a custom digest give you the same security and no library, no documentation, and no tests but your own. A published scheme also means a sender can integrate with you in an afternoon.
Do not log the payload on a verification failure. A failing delivery is exactly the one most likely to be hostile, and copying it into your logs puts unvalidated content where people read it.
Do not treat the tolerance as a tuning knob for an unreliable queue. Widening it to an hour to stop retries failing hands an attacker an hour of replay window, and the real fix is in whatever delayed the delivery. Five minutes is the usual figure, and moving it is a decision worth recording.3
Related how-tos
Last verified
Verified 2026-09-14 against PHP 8.4.19. Both output blocks are what the preceding command printed.
Footnotes
-
The stream has a manual entry of two sentences.
php://inputis a read-only stream that allows you to read raw data from the request body, and it is not available in a POST withenctype="multipart/form-data"whenenable_post_data_readingis on. The second sentence describes the one case where PHP has already read the body for you, which is the case this page is about, seen from the other side. ↩︎ Back to text -
hash_equalsarrived in PHP 5.6 under the manual heading timing attack safe string comparison, and its two parameters are namedknown_stringanduser_stringin that order. The manual is firm that the user’s string goes second, and explains why further down. When the lengths differ the function returns at once, and the length of the known string may be leaked. A function whose purpose is not to leak keeps one leak, documents it, and asks the caller to arrange the arguments around it. ↩︎ Back to text -
The specification the page verifies against gives the tolerance no number. Its verification advice asks for a timestamp within some allowable tolerance of the current time, and stops there. Five minutes does appear twice in the same document. Once it is the suggested lifetime for stored message ids, and once a row in an example retry schedule, between five seconds and thirty minutes. Neither is the tolerance. Stripe’s webhook documentation gives its libraries a default tolerance of 5 minutes, and the specification gives none. ↩︎ Back to text