How to Verify Webhook Signatures in PHP and Laravel
Verify webhook HMAC signatures safely in PHP and Laravel using the raw request body, constant-time comparison, timestamp checks, and secret rotation.
Verify the provider’s exact signed message before parsing or queuing: use the raw bytes, a server-side secret, constant-time comparison, a bounded timestamp, and idempotent event processing.
A public webhook URL can be called by anyone who discovers it. TLS protects the request in transit, but it does not prove that the sender is your payment, messaging, or deployment provider. Signature verification authenticates the payload before your application trusts it.
There is no universal webhook signature format. Use the provider’s current documentation for the header name, algorithm, signed bytes, timestamp format, and encoding. The example below shows a common HMAC pattern, not a drop-in adapter for every provider.
Preserve the raw request body
Signatures are calculated over exact bytes. Parsing JSON and serializing it again can change whitespace, key order, escaping, or number formatting.
In plain PHP:
$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
http_response_code(400);
exit('Unable to read request body');
}
In Laravel:
$rawBody = $request->getContent();
Do this before modifying the payload. You can decode JSON after signature verification:
$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
Do not verify json_encode($request->all()); those are not necessarily the bytes the provider signed.
Build the exact signed message
Many providers sign the raw body. Others sign a timestamp plus a separator plus the body:
$signedMessage = $timestamp . '.' . $rawBody;
Small differences matter: a newline, colon instead of a period, header prefix, hex versus Base64, or raw versus decoded bytes produces a different result.
Parse the signature header strictly. If it can contain multiple values for secret rotation, retain only the documented fields. Reject missing, malformed, or excessively large headers before expensive processing.
Calculate and compare the HMAC
For a hexadecimal HMAC-SHA-256 scheme:
$expected = hash_hmac(
'sha256',
$signedMessage,
$webhookSecret
);
$valid = hash_equals($expected, $providedSignature);
hash_equals() performs timing-attack-safe string comparison. Pass the server-computed value as the first argument and compare values in the same encoding.
For a Base64 scheme, the provider may expect either Base64 of the raw HMAC bytes:
$expected = base64_encode(
hash_hmac('sha256', $signedMessage, $webhookSecret, true)
);
or a different representation. Follow the provider rather than trying both formats until one matches.
Keep the secret in an environment-backed secret store. Never place it in client-side JavaScript, source control, exception pages, analytics, or normal request logs.
Add a timestamp tolerance
A valid signature can be replayed if an attacker captures the complete signed request. When the provider includes a signed timestamp, reject requests too far from the server’s current time:
$tolerance = 300;
if (abs(time() - $timestamp) > $tolerance) {
abort(401, 'Webhook timestamp outside tolerance');
}
Validate that the timestamp is an integer and in the expected unit. Keep server clocks synchronized with NTP. Check the timestamp as part of the signed message; an unsigned timestamp can be changed by an attacker.
Timestamp tolerance limits the replay window but does not replace idempotency. The same valid event may be retried legitimately within that window.
Put verification in a focused Laravel service
Keep provider-specific logic out of the controller:
final class WebhookVerifier
{
public function verify(
string $rawBody,
string $signature,
int $timestamp,
string $secret
): bool {
if (abs(time() - $timestamp) > 300) {
return false;
}
$message = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $message, $secret);
return hash_equals($expected, $signature);
}
}
The controller order should be:
- Read the raw body and signature headers.
- Validate header syntax and timestamp.
- Verify the signature.
- Parse and validate the JSON schema.
- Claim the provider event ID idempotently.
- Queue processing and return quickly.
Return a generic 400 or 401 for invalid requests. Detailed verification failures belong in restricted operational logs, not in the response.
Rotate secrets without downtime
During rotation, accept signatures produced by the current secret and a previous secret for a short, documented overlap:
$valid = collect([$currentSecret, $previousSecret])
->filter()
->contains(fn (string $secret) =>
$verifier->verify($rawBody, $signature, $timestamp, $secret)
);
Remove the previous secret after the provider is confirmed on the new one. If the provider includes a key identifier, use it to select the correct secret instead of trying every historical key.
Store different secrets per provider, environment, and tenant when supported. A staging secret should never authenticate production events.
Test with raw fixtures
Keep sanitized provider fixtures containing the raw body, timestamp, header, and expected result. Tests should cover:
- A valid signature.
- One changed body byte.
- A wrong secret.
- A malformed or missing header.
- Hex/Base64 confusion.
- An expired timestamp.
- Multiple signatures during rotation.
- Unicode and escaped JSON.
Test through Laravel’s HTTP layer as well as the verifier unit. Middleware that reads or transforms the body can create integration failures a unit test will miss.
Never log secrets or full sensitive fixtures. Log a request ID, provider, event ID after verification, verification outcome, and a safe reason code.
Prevent duplicate processing after verification
A signature proves origin and integrity; it does not guarantee one delivery. Record the provider event ID behind a unique database constraint and make side effects retry-safe. The Laravel webhook idempotency guide provides the implementation pattern, while Reliable webhook integrations covers queues, retries, and observability.
Common mistakes
The recurring mistakes are verifying re-encoded JSON, using === for secret comparisons, ignoring timestamps, applying a generic algorithm to a provider-specific format, logging the secret, and performing business work before authentication.
Treat the verifier as a small security boundary: minimal inputs, exact byte rules, explicit failure behavior, and test vectors captured from official provider tooling.
Frequently asked questions
Can an IP allowlist replace webhook signature verification?
Usually no. Provider address ranges can change, requests may pass through shared infrastructure, and network controls do not prove payload integrity. Use allowlisting as defense in depth when the provider supports it.
Can I parse JSON before checking the signature?
You may parse a copy, but signature calculation must use the untouched raw bytes. The safest sequence is to verify first, then parse and validate the authenticated body.
Official references
Have a question about this guide or an idea for a technical collaboration? Contact Bakry through the Dev Hub.
End of field note.