Verify webhook signatures5 min read
Verify webhook signatures in PHP and Laravel
Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
Read the raw body from php://input, take the three webhook-* headers, and compute base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)) with the base64-decoded whsec_ secret as the key. Compare it with each v1 signature using hash_equals, and reject timestamps more than 5 minutes off. In Laravel, use $request->getContent(), the standard-webhooks/standard-webhooks package, and exclude the route from CSRF checks.
Two setups: a single PHP file that runs on any host with PHP 8, including shared hosting, and a Laravel controller. Both read the same three headers and the same raw body; they differ in where those live and what stands in the way.
What's different in PHP
PHP fills $_POST only for form encodings. Ikiform sends JSON, so $_POST is empty and the body is only in php://input, which is exactly the raw bytes you need. Headers arrive in $_SERVER as HTTP_WEBHOOK_ID and so on. In Laravel the trap is different: every route in routes/web.php goes through CSRF verification, and a webhook has no CSRF token, so Laravel answers 419 before your code runs.
- By default, a POST to a route in routes/web.php passes through Laravel's CSRF check.
- Ikiform's delivery has no CSRF token, so Laravel answers 419 and the controller never runs.
- With the path listed in validateCsrfTokens except, the request reaches the controller.
- The controller verifies the raw body with the Standard Webhooks library and answers 204.
Plain PHP
<?php
declare(strict_types=1);
const IKIFORM_TOLERANCE_SECONDS = 300;
function verify_ikiform_signature(string $body, array $headers, string $secret, ?int $now = null): ?string
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
return 'missing_headers';
}
if (preg_match('/^[0-9]{1,12}$/', $timestamp) !== 1) {
return 'invalid_timestamp';
}
$age = ($now ?? time()) - (int) $timestamp;
if ($age > IKIFORM_TOLERANCE_SECONDS) {
return 'timestamp_too_old';
}
if ($age < -IKIFORM_TOLERANCE_SECONDS) {
return 'timestamp_too_new';
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false || $key === '') {
return 'invalid_secret';
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $value] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return null;
}
}
return 'no_matching_signature';
}<?php
declare(strict_types=1);
require __DIR__ . '/verify.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$body = file_get_contents('php://input');
$headers = [
'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
];
$failure = verify_ikiform_signature($body, $headers, getenv('IKIFORM_WEBHOOK_SECRET') ?: '');
if ($failure !== null) {
http_response_code(400);
echo $failure;
exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if ($event['type'] === 'submission.created') {
error_log('Ikiform submission ' . $event['data']['submission_id']);
}
http_response_code(204);php://inputcan be read more than once, so a framework or library that has already read it doesn't stop you from reading it again.hash_hmac(..., true)returns raw bytes, which are then base64-encoded. Withouttrueyou get hex, which never matches.base64_decode(..., true)is strict: a damaged secret returnsfalseinstead of quietly decoding to the wrong key.hash_equalsis constant-time. Pass the value you computed first and the one from the request second.- Interpolating
$bodyinto the string is binary-safe in PHP, so non-ASCII characters in a submission don't change anything. $_SERVER['HTTP_WEBHOOK_ID']works under Apache, nginx with PHP-FPM and the built-in server, unlikegetallheaders()on some older setups.- Keep
IKIFORM_WEBHOOK_SECRETin the server's environment or a file outside the web root, never inwebhook.php.
Checking it with the test vector
This script runs the test vector through the function. Run it with php check-signature.php; it exits non-zero on any failure, so it can run in CI as it is or become PHPUnit cases later:
<?php
declare(strict_types=1);
require __DIR__ . '/verify.php';
$secret = 'whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=';
$rolled = 'whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=';
$body = '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}';
$headers = [
'webhook-id' => 'dlv_01k7v2d4n8r2p6s0w3y5b7c9e1',
'webhook-timestamp' => '1792300000',
'webhook-signature' => 'v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=',
];
$now = 1792300000 + 30;
$both = ['webhook-signature' => $headers['webhook-signature'] . ' v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak='] + $headers;
$pretty = json_encode(json_decode($body), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$cases = [
'accepts the vector' => [verify_ikiform_signature($body, $headers, $secret, $now), null],
'accepts the old secret during a roll' => [verify_ikiform_signature($body, $both, $secret, $now), null],
'accepts the new secret during a roll' => [verify_ikiform_signature($body, $both, $rolled, $now), null],
'rejects a re-serialized body' => [verify_ikiform_signature($pretty, $headers, $secret, $now), 'no_matching_signature'],
'rejects a stale timestamp' => [verify_ikiform_signature($body, $headers, $secret, $now + 600), 'timestamp_too_old'],
];
$failed = 0;
foreach ($cases as $name => [$got, $want]) {
$ok = $got === $want;
$failed += $ok ? 0 : 1;
printf("%s %s\n", $ok ? 'ok ' : 'FAIL', $name);
}
exit($failed === 0 ? 0 : 1);Answering first on plain PHP
A plain PHP script normally sends its response when it ends, so slow work in webhook.php delays Ikiform's 2xx, and anything over 15 seconds counts as a failure and is retried. Under PHP-FPM, fastcgi_finish_request() sends the response and lets the script carry on. This version of the end of webhook.php answers first, then records the delivery id with a unique constraint so a retry is skipped:
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
http_response_code(204);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
$pdo = new PDO(getenv('DATABASE_DSN'), getenv('DATABASE_USER'), getenv('DATABASE_PASSWORD'));
$insert = $pdo->prepare('INSERT INTO webhook_deliveries (delivery_id) VALUES (?) ON CONFLICT (delivery_id) DO NOTHING');
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1 && $event['type'] === 'submission.created') {
error_log('Ikiform submission ' . $event['data']['submission_id']);
}fastcgi_finish_request only exists under PHP-FPM, hence the check; under Apache's mod_php the script runs to the end before the answer goes out. The ON CONFLICT clause is PostgreSQL and SQLite syntax. On MySQL, use INSERT IGNORE, which also reports a row count of 0 for a repeat.
Laravel
Install the Standard Webhooks library with Composer:
composer require standard-webhooks/standard-webhooks<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use StandardWebhooks\Exception\WebhookVerificationException;
use StandardWebhooks\Webhook;
class IkiformWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
$webhook = new Webhook(config('services.ikiform.webhook_secret'));
try {
$event = $webhook->verify($request->getContent(), [
'webhook-id' => $request->header('webhook-id'),
'webhook-timestamp' => $request->header('webhook-timestamp'),
'webhook-signature' => $request->header('webhook-signature'),
]);
} catch (WebhookVerificationException $exception) {
return response($exception->getMessage(), 400);
}
if ($event['type'] === 'submission.created') {
Log::info('Ikiform submission', ['id' => $event['id'], 'fields' => $event['data']['fields']]);
}
return response()->noContent();
}
}<?php
use App\Http\Controllers\IkiformWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/ikiform', IkiformWebhookController::class);Exclude the path from CSRF verification in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/ikiform',
]);
})And read the secret through config, so php artisan config:cache keeps working:
'ikiform' => [
'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],$request->getContent()is the raw body. Don't verifyjson_encode($request->all()); it's the same data and different bytes.- The library looks headers up by exact lowercase keys, so build the array explicitly as shown. Passing
$request->headers->all()doesn't work: those values are arrays. new Webhook($secret)stripswhsec_and decodes the key. It throwsWebhookVerificationExceptionfor a missing header, an old or future timestamp, or no matching signature.response()->noContent()answers204. Queue slow work with a job so the answer goes back well inside Ikiform's 15 seconds. A unique index on adelivery_idcolumn, written withinsertOrIgnore, makes the job safe to receive twice.
routes/api.php skip CSRF without any exception and get an /api prefix. If your app already has API routes, put the webhook there instead and skip the bootstrap/app.php change.Check it works
- Run
php check-signature.php. Every line starts withok. - Deploy behind a public
https:URL and add it under Webhooks in the dashboard. - Set
IKIFORM_WEBHOOK_SECRETto the endpoint'swhsec_secret. In Laravel, runphp artisan config:clearif config is cached. - Choose Send test. The delivery log shows
204, not419, and the first 4 KB of any error body your code printed. - Change one character of the secret and send another test. The log shows
400with the reason.
Gotchas
| Problem | Cause and fix |
|---|---|
419 in the delivery log | Laravel's CSRF check. Add the path to validateCsrfTokens(except: [...]) |
$_POST is empty | Expected for JSON. Read file_get_contents('php://input') |
| Signature never matches in plain PHP | hash_hmac was called without true, so it returned hex. Or the key is the whole whsec_ string instead of its decoded bytes |
Missing required headers from the library | The headers array has the wrong keys or array values. Use lowercase keys and $request->header(), which returns a string |
The secret is null in production | Config is cached and env() was called outside a config file. Read it through config('services.ikiform.webhook_secret') |
Questions
Read file_get_contents('php://input'). For a JSON request, $_POST is empty, and php://input holds the body exactly as it was sent, which is what the signature covers.
Routes in routes/web.php are CSRF-protected, and a webhook can't send a CSRF token. Exclude the path with validateCsrfTokens(except: [...]) in bootstrap/app.php, or define the route in routes/api.php.
Yes. It's PHP's constant-time string comparison. Use it instead of === so the time taken doesn't reveal how much of a forged signature was right.
standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) takes the whsec_ secret, and verify($body, $headers) returns the decoded payload or throws WebhookVerificationException.
By default hash_hmac returns hex. Pass true as the fourth argument to get raw bytes, then base64_encode them. Also check that the key is the base64-decoded part after whsec_, not the whole secret string.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.