Webhook-Signaturen prüfen5 Min. Lesezeit
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.
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.
- Standardmäßig durchläuft ein POST an eine Route in routes/web.php die CSRF-Prüfung von Laravel.
- Die Zustellung von Ikiform hat kein CSRF-Token, also antwortet Laravel mit 419, und der Controller läuft nie.
- Steht der Pfad in validateCsrfTokens except, erreicht der Request den Controller.
- Der Controller verifiziert den rohen Body mit der Standard-Webhooks-Bibliothek und antwortet mit 204.
Reines 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://inputlä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. Ohnetruebekommst du Hex, das nie passt.base64_decode(..., true)ist strikt: Ein beschädigtes Secret gibtfalsezurück, statt still zum falschen Key dekodiert zu werden.hash_equalsarbeitet in konstanter Zeit. Übergib zuerst den berechneten Wert und als zweites den aus dem Request.$bodyin 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 alsgetallheaders()auf manchen älteren Setups.- Halte
IKIFORM_WEBHOOK_SECRETin der Umgebung des Servers oder in einer Datei außerhalb des Web-Roots, nie inwebhook.php.
Mit dem Testvektor prüfen
Dieses Skript lässt den Testvektor 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:
<?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:
$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:
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);Nimm den Pfad in bootstrap/app.php von der CSRF-Prüfung aus:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/ikiform',
]);
})Und lies das Secret über die Config, damit php artisan config:cache weiter funktioniert:
'ikiform' => [
'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],$request->getContent()ist der rohe Body. Verifiziere nichtjson_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)entferntwhsec_und dekodiert den Key. Es wirftWebhookVerificationExceptionbei einem fehlenden Header, einem alten oder zukünftigen Zeitstempel oder wenn keine Signatur passt.response()->noContent()antwortet mit204. 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 Spaltedelivery_id, geschrieben mitinsertOrIgnore, macht den Job sicher gegen doppelten Empfang.
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
- Führe
php check-signature.phpaus. Jede Zeile beginnt mitok. - Deploye hinter einer öffentlichen
https:-URL und füge sie im Dashboard unter Webhooks hinzu. - Setze
IKIFORM_WEBHOOK_SECRETauf daswhsec_-Secret des Endpunkts. Führe in Laravelphp artisan config:clearaus, wenn die Config gecacht ist. - Wähle Send test. Das Zustellungsprotokoll zeigt
204, nicht419, und die ersten 4 KB jedes Fehler-Bodys, den dein Code ausgegeben hat. - Ändere ein Zeichen des Secrets und sende einen weiteren Test. Das Protokoll zeigt
400mit 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
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.
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.