Verify webhook signatures

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.

Preet SutharFounder, Ikiform

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.

An Ikiform webhook reaching a Laravel route with and without a CSRF exceptionroutes/web.php, default middlewareroute excluded from CSRFPOST/webhooks/ikiformCSRF checkno token in therequest419 PageExpiredPOST/webhooks/ikiformcontroller$request->getContent()verifyStandardWebhooks204An Ikiform webhook reaching a Laravel route with and without a CSRF exceptionroutes/web.php, default middlewareroute excluded from CSRFPOST /webhooks/ikiformCSRF checkno token in the request419 Page ExpiredPOST /webhooks/ikiformcontroller$request->getContent()verifyStandard Webhooks204
  1. By default, a POST to a route in routes/web.php passes through Laravel's CSRF check.
  2. Ikiform's delivery has no CSRF token, so Laravel answers 419 and the controller never runs.
  3. With the path listed in validateCsrfTokens except, the request reaches the controller.
  4. The controller verifies the raw body with the Standard Webhooks library and answers 204.
The signature replaces CSRF for this route. Every other route keeps its protection.

Plain PHP

verify.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';
}
webhook.php
<?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://input can 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. Without true you get hex, which never matches.
  • base64_decode(..., true) is strict: a damaged secret returns false instead of quietly decoding to the wrong key.
  • hash_equals is constant-time. Pass the value you computed first and the one from the request second.
  • Interpolating $body into 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, unlike getallheaders() on some older setups.
  • Keep IKIFORM_WEBHOOK_SECRET in the server's environment or a file outside the web root, never in webhook.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:

check-signature.php
<?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:

webhook.php
$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:

Terminal
composer require standard-webhooks/standard-webhooks
app/Http/Controllers/IkiformWebhookController.php
<?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();
    }
}
routes/web.php
<?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:

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:

config/services.php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
  • $request->getContent() is the raw body. Don't verify json_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) strips whsec_ and decodes the key. It throws WebhookVerificationException for a missing header, an old or future timestamp, or no matching signature.
  • response()->noContent() answers 204. Queue slow work with a job so the answer goes back well inside Ikiform's 15 seconds. A unique index on a delivery_id column, written with insertOrIgnore, makes the job safe to receive twice.
Routes in 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

  1. Run php check-signature.php. Every line starts with ok.
  2. Deploy behind a public https: URL and add it under Webhooks in the dashboard.
  3. Set IKIFORM_WEBHOOK_SECRET to the endpoint's whsec_ secret. In Laravel, run php artisan config:clear if config is cached.
  4. Choose Send test. The delivery log shows 204, not 419, and the first 4 KB of any error body your code printed.
  5. Change one character of the secret and send another test. The log shows 400 with the reason.

Gotchas

ProblemCause and fix
419 in the delivery logLaravel's CSRF check. Add the path to validateCsrfTokens(except: [...])
$_POST is emptyExpected for JSON. Read file_get_contents('php://input')
Signature never matches in plain PHPhash_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 libraryThe headers array has the wrong keys or array values. Use lowercase keys and $request->header(), which returns a string
The secret is null in productionConfig is cached and env() was called outside a config file. Read it through config('services.ikiform.webhook_secret')

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.