# Webhook-Signaturen in PHP und Laravel verifizieren

> Verifiziere Ikiform-Webhook-Signaturen in reinem PHP mit hash_hmac und hash_equals und in Laravel mit der Standard-Webhooks-Bibliothek und einer CSRF-Ausnahme.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/webhook-signature-verification/php

Lies den rohen Body aus `php://input`, nimm die drei `webhook-*`-Header und berechne `base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true))` mit dem base64-dekodierten `whsec_`-Secret als Key. Vergleiche es mit `hash_equals` mit jeder `v1`-Signatur und lehne Zeitstempel ab, die mehr als 5 Minuten abweichen. Nutze in Laravel `$request->getContent()` und das Paket `standard-webhooks/standard-webhooks` und nimm die Route von der CSRF-Prüfung aus.

Zwei Setups: eine einzelne PHP-Datei, die auf jedem Host mit PHP 8 läuft, auch auf Shared Hosting, und ein Laravel-Controller. Beide lesen dieselben drei Header und denselben rohen Body; sie unterscheiden sich darin, wo diese liegen und was im Weg steht.

## Was in PHP anders ist

PHP füllt `$_POST` nur bei Formular-Kodierungen. Ikiform sendet JSON, also ist `$_POST` leer, und der Body steht nur in `php://input`, genau die rohen Bytes, die du brauchst. Header kommen in `$_SERVER` als `HTTP_WEBHOOK_ID` und so weiter an. In Laravel ist die Falle eine andere: Jede Route in `routes/web.php` läuft durch die CSRF-Prüfung, und ein Webhook hat kein CSRF-Token, also antwortet Laravel mit `419`, bevor dein Code läuft.

Diagramm: Ein Ikiform-Webhook, der eine Laravel-Route mit und ohne CSRF-Ausnahme erreicht

1. Standardmäßig durchläuft ein POST an eine Route in routes/web.php die CSRF-Prüfung von Laravel.
2. Die Zustellung von Ikiform hat kein CSRF-Token, also antwortet Laravel mit 419, und der Controller läuft nie.
3. Steht der Pfad in validateCsrfTokens except, erreicht der Request den Controller.
4. Der Controller verifiziert den rohen Body mit der Standard-Webhooks-Bibliothek und antwortet mit 204.

Die Signatur ersetzt CSRF für diese Route. Jede andere Route behält ihren Schutz.

## Reines 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` lässt sich mehr als einmal lesen, also hindert dich ein Framework oder eine Bibliothek, die es schon gelesen hat, nicht daran, es erneut zu lesen.
- `hash_hmac(..., true)` gibt rohe Bytes zurück, die dann base64-kodiert werden. Ohne `true` bekommst du Hex, das nie passt.
- `base64_decode(..., true)` ist strikt: Ein beschädigtes Secret gibt `false` zurück, statt still zum falschen Key dekodiert zu werden.
- `hash_equals` arbeitet in konstanter Zeit. Übergib zuerst den berechneten Wert und als zweites den aus dem Request.
- `$body` in den String einzusetzen ist in PHP binärsicher, also ändern Nicht-ASCII-Zeichen in einer Einsendung nichts.
- `$_SERVER['HTTP_WEBHOOK_ID']` funktioniert unter Apache, nginx mit PHP-FPM und dem eingebauten Server, anders als `getallheaders()` auf manchen älteren Setups.
- Halte `IKIFORM_WEBHOOK_SECRET` in der Umgebung des Servers oder in einer Datei außerhalb des Web-Roots, nie in `webhook.php`.

### Mit dem Testvektor prüfen

Dieses Skript lässt den [Testvektor](https://ikiform.com/de/guides/webhook-signature-verification.md#test-vector) durch die Funktion laufen. Führe es mit `php check-signature.php` aus; bei jedem Fehler endet es mit einem Exit-Code ungleich null, kann also so wie es ist in CI laufen oder später zu PHPUnit-Fällen werden:

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

### In reinem PHP zuerst antworten

Ein reines PHP-Skript sendet seine Antwort normalerweise, wenn es endet, also verzögert langsame Arbeit in `webhook.php` das `2xx` an Ikiform, und alles über 15 Sekunden zählt als Fehlschlag und wird wiederholt. Unter PHP-FPM sendet `fastcgi_finish_request()` die Antwort und lässt das Skript weiterlaufen. Diese Version des Endes von `webhook.php` antwortet zuerst und speichert dann die Zustellungs-ID mit einem Unique Constraint, sodass eine Wiederholung übersprungen wird:

`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` gibt es nur unter PHP-FPM, daher die Prüfung; unter `mod_php` von Apache läuft das Skript bis zum Ende, bevor die Antwort rausgeht. Die Klausel `ON CONFLICT` ist Syntax von PostgreSQL und SQLite. Nutze auf MySQL `INSERT IGNORE`, das bei einer Wiederholung ebenfalls eine Zeilenzahl von `0` meldet.

## Laravel

Installiere die Standard-Webhooks-Bibliothek mit 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);
```

Nimm den Pfad in `bootstrap/app.php` von der CSRF-Prüfung aus:

`bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'webhooks/ikiform',
    ]);
})
```

Und lies das Secret über die Config, damit `php artisan config:cache` weiter funktioniert:

`config/services.php`:

```php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
```

- `$request->getContent()` ist der rohe Body. Verifiziere nicht `json_encode($request->all())`; das sind dieselben Daten, aber andere Bytes.
- Die Bibliothek sucht Header über exakte kleingeschriebene Keys, also baue das Array wie gezeigt explizit. `$request->headers->all()` zu übergeben funktioniert nicht: Diese Werte sind Arrays.
- `new Webhook($secret)` entfernt `whsec_` und dekodiert den Key. Es wirft `WebhookVerificationException` bei einem fehlenden Header, einem alten oder zukünftigen Zeitstempel oder wenn keine Signatur passt.
- `response()->noContent()` antwortet mit `204`. Lege langsame Arbeit als Job in die Queue, damit die Antwort deutlich innerhalb der 15 Sekunden von Ikiform zurückgeht. Ein eindeutiger Index auf einer Spalte `delivery_id`, geschrieben mit `insertOrIgnore`, macht den Job sicher gegen doppelten Empfang.

> Routen in `routes/api.php` überspringen CSRF ohne Ausnahme und bekommen ein `/api`-Präfix. Hat deine App schon API-Routen, leg den Webhook stattdessen dort ab und lass die Änderung an `bootstrap/app.php` weg.

## Prüfen, ob es funktioniert

1. Führe `php check-signature.php` aus. Jede Zeile beginnt mit `ok`.
2. Deploye hinter einer öffentlichen `https:`-URL und füge sie im Dashboard unter **Webhooks** hinzu.
3. Setze `IKIFORM_WEBHOOK_SECRET` auf das `whsec_`-Secret des Endpunkts. Führe in Laravel `php artisan config:clear` aus, wenn die Config gecacht ist.
4. Wähle **Send test**. Das Zustellungsprotokoll zeigt `204`, nicht `419`, und die ersten 4 KB jedes Fehler-Bodys, den dein Code ausgegeben hat.
5. Ändere ein Zeichen des Secrets und sende einen weiteren Test. Das Protokoll zeigt `400` mit dem Grund.

## Stolperfallen

| Problem | Ursache und Lösung |
| --- | --- |
| `419` im Zustellungsprotokoll | Die CSRF-Prüfung von Laravel. Füge den Pfad zu `validateCsrfTokens(except: [...])` hinzu |
| `$_POST` ist leer | Bei JSON erwartet. Lies `file_get_contents('php://input')` |
| Die Signatur passt in reinem PHP nie | `hash_hmac` wurde ohne `true` aufgerufen und hat daher Hex zurückgegeben. Oder der Key ist der ganze `whsec_`-String statt seiner dekodierten Bytes |
| `Missing required headers` von der Bibliothek | Das Header-Array hat falsche Keys oder Array-Werte. Nutze kleingeschriebene Keys und `$request->header()`, das einen String zurückgibt |
| Das Secret ist in Produktion `null` | Die Config ist gecacht, und `env()` wurde außerhalb einer Config-Datei aufgerufen. Lies es über `config('services.ikiform.webhook_secret')` |

## Fragen

### Wie bekomme ich in PHP den rohen POST-Body?

Lies file_get_contents('php://input'). Bei einem JSON-Request ist $_POST leer, und php://input enthält den Body genau so, wie er gesendet wurde, und genau das deckt die Signatur ab.

### Warum gibt Laravel für meinen Webhook 419 zurück?

Routen in routes/web.php sind CSRF-geschützt, und ein Webhook kann kein CSRF-Token senden. Nimm den Pfad mit validateCsrfTokens(except: [...]) in bootstrap/app.php aus oder definiere die Route in routes/api.php.

### Ist hash_equals sicher für den Vergleich von Webhook-Signaturen?

Ja. Es ist der String-Vergleich in konstanter Zeit von PHP. Nutze es statt ===, damit die benötigte Zeit nicht verrät, wie viel einer gefälschten Signatur stimmte.

### Welches Composer-Paket verifiziert Standard Webhooks in PHP?

standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) nimmt das whsec_-Secret, und verify($body, $headers) gibt den dekodierten Payload zurück oder wirft WebhookVerificationException.

### Warum passt mein Ergebnis von hash_hmac nicht zum Header webhook-signature?

Standardmäßig gibt hash_hmac Hex zurück. Übergib true als viertes Argument, um rohe Bytes zu bekommen, und kodiere sie dann mit base64_encode. Prüfe außerdem, dass der Key der base64-dekodierte Teil nach whsec_ ist, nicht der ganze Secret-String.

## Weitere Anleitungen

- [Webhook-Signaturen prüfen](https://ikiform.com/de/guides/webhook-signature-verification.md): Prüfe, ob ein Formular-Webhook wirklich von Ikiform kommt: das HMAC-SHA256-Schema von Standard Webhooks, ein Testvektor, Raw Bodys, Zeitstempel und Secret-Rotation.
- [Ruby](https://ikiform.com/de/guides/webhook-signature-verification/ruby.md): Verifiziere Ikiform-Webhook-Signaturen in Sinatra mit OpenSSL und einem Minitest-Test und in Rails mit dem Gem standardwebhooks und request.raw_post.
- [Python](https://ikiform.com/de/guides/webhook-signature-verification/python.md): Prüfe Webhook-Signaturen von Ikiform in Flask, FastAPI und Django, mit dem Paket standardwebhooks oder der Standardbibliothek, plus einem Test mit pytest.
- [Node.js](https://ikiform.com/de/guides/webhook-signature-verification/nodejs.md): Prüfe Webhook-Signaturen von Ikiform in Node.js: Raw Bodys in Express und Fastify, das SDK, das Paket standardwebhooks und eine getestete Version mit node:crypto.
- [Go net/http](https://ikiform.com/de/guides/webhook-signature-verification/go.md): Verifiziere Ikiform-Webhook-Signaturen in Go mit net/http: Lies den Body einmal, prüf ihn mit crypto/hmac, teste ihn oder nutze die Standard-Webhooks-Bibliothek.
