Debugging a failed verification
Debugging a failed verification
Start here: ask the package
php artisan webhook:verify github \
--file=captured-body.json \
--header='X-Hub-Signature-256: sha256=…'
Paste the body and headers of the delivery that was refused, and it will tell you the
typed reason plus what usually causes it. Headers can also come from a file — one
Name: value per line, which is what copying them out of a provider's delivery log gives
you — with --headers=headers.txt.
The reason this beats re-deriving the signature by hand: a captured delivery has always
aged out of its freshness window by the time it reaches your terminal, so a naive re-check
reports stale_timestamp and tells you nothing about whether the signature was ever valid.
The command notices that, re-checks with the window disabled, and says which of the two you
are looking at:
Refused: The timestamp is older than the tolerance window. (stale_timestamp)
The signature itself is valid — only the freshness window rejected it.
Signed at 1735689600, which is 86400 seconds ago.
That is a clock problem. If instead it reports that the signature does not verify even with the window disabled, the timestamp was never the issue and you are looking at one of the five causes below.
Use --at=<unix timestamp> to evaluate the delivery at the moment it was signed.
Reading the failure reason
Start with the failure reason. Every refusal carries one, and it narrows the search immediately:
try {
$verified = Webhooks::verify('github', WebhookMessage::fromRequest($request));
} catch (SignatureVerificationFailed $e) {
Log::warning('Webhook refused', $e->context());
// ['scheme' => 'github', 'reason' => 'signature_mismatch', 'configuration_fault' => false]
}
The middleware logs this for you at warning level.
| Reason | What it means |
|---|---|
no_secret_configured |
Ours. The endpoint has no usable secret — usually an unset env var |
missing_signature |
The header was absent entirely |
malformed_signature |
Present but unparseable, or the wrong encoding/length |
unsupported_version |
A version marker this scheme does not implement |
missing_timestamp / malformed_timestamp |
The timestamp was absent or not a unix timestamp |
stale_timestamp / future_timestamp |
Outside the window — look at clocks first |
signature_mismatch |
The MAC did not match any configured secret |
replayed |
Already processed, per the replay guard |
signature_mismatch — the five causes
In the order they actually occur:
1. The body was modified before verification
By far the most common. A MAC is computed over bytes, so decoding JSON and re-encoding it changes key order, unicode escaping and whitespace, and nothing matches.
Check for middleware that reads and rewrites the body ahead of webhook.signature. Confirm
the raw body is intact:
Log::debug('raw body', ['sha256' => hash('sha256', $request->getContent())]);
Compare that against the same digest computed by the sender. If they differ, the bytes changed in transit and the signature is a symptom, not the cause.
2. The wrong secret
Endpoint secret vs API key vs account token — providers offer several and they look alike.
Check for whitespace from a copy-paste, and for a .env value that never made it into a
cached config (php artisan config:clear).
For Standard Webhooks specifically: paste the whsec_… string verbatim. The scheme strips
and decodes the prefix itself.
3. Test vs live credentials
Stripe test mode and live mode have different webhook secrets. So do most providers' sandboxes. An endpoint that verifies in staging and fails in production is usually this.
4. Twilio only: the URL
The signature covers the request URL. Behind a load balancer with TrustProxies
unconfigured, the URL your application reconstructs is not the one Twilio dialled — wrong
scheme (http vs https), wrong host, or a dropped port. Fix trusted proxies before
looking anywhere else.
5. Genuinely someone else
If the above are all clean and the volume is low, this is what a rejection is supposed to look like. That is the control doing its job.
stale_timestamp — check the clock
Almost always host clock drift rather than a real replay. Confirm NTP is running and compare the server clock against the timestamp in the delivery. A server minutes behind rejects genuine deliveries as future-dated; one minutes ahead rejects them as stale.
The second cause is a retry storm: a provider retrying a delivery from an hour ago will
legitimately fall outside the window. Widen tolerance only if you understand which of the
two you are looking at — a wider window is a weaker replay defence.
Reproduce it locally
php artisan webhook:sign github --body='{"action":"opened"}' --curl
Signs with the endpoint's real secret and prints a runnable curl command. If that verifies against your local route and the provider's delivery does not, the difference is in transport or configuration rather than in the signing.
Watch it happen in production
Refusals are announced as events, so you do not have to reproduce anything to see the pattern:
Event::listen(fn (WebhookRefused $e) => Log::warning('webhook refused', [
'endpoint' => $e->endpoint,
'reason' => $e->reason->value,
]));
See events. A spike of one reason is usually more informative than any single failed delivery.
Confirm the control still works
$this->postUnsignedWebhook('/webhooks/github', ['action' => 'opened'])
->assertUnauthorized();
Worth having in the suite permanently. A verification that has been accidentally disabled looks exactly like one that is working, right up until it does not.