# 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.

Published 2026-10-25 · https://ikiform.com/guides/webhook-signature-verification/php

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.

Diagram: An Ikiform webhook reaching a Laravel route with and without a CSRF exception

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
<?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
<?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](https://ikiform.com/guides/webhook-signature-verification.md#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
<?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`:

```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`:

```bash
composer require standard-webhooks/standard-webhooks
```

`app/Http/Controllers/IkiformWebhookController.php`:

```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
<?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`:

```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`:

```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

| 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

### How do I get the raw POST body in PHP?

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.

### Why does Laravel return 419 for my webhook?

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.

### Is hash_equals safe for comparing webhook signatures?

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.

### Which Composer package verifies Standard Webhooks in PHP?

standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) takes the whsec_ secret, and verify($body, $headers) returns the decoded payload or throws WebhookVerificationException.

### Why doesn't my hash_hmac result match the webhook-signature header?

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.

## More guides

- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [Ruby](https://ikiform.com/guides/webhook-signature-verification/ruby.md): Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [Node.js](https://ikiform.com/guides/webhook-signature-verification/nodejs.md): Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
- [Go net/http](https://ikiform.com/guides/webhook-signature-verification/go.md): Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
