Skip to content

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.