Webhook-Signaturen prüfen

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.

Preet SutharGründer, Ikiform

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.

Ein Ikiform-Webhook, der eine Laravel-Route mit und ohne CSRF-Ausnahme erreichtroutes/web.php, Standard-MiddlewareRoute von CSRF ausgenommenPOST/webhooks/ikiformCSRF-Prüfungkein Token imRequest419 PageExpiredPOST/webhooks/ikiformController$request->getContent()verifizierenStandardWebhooks204Ein Ikiform-Webhook, der eine Laravel-Route mit und ohne CSRF-Ausnahme erreichtroutes/web.php, Standard-MiddlewareRoute von CSRF ausgenommenPOST /webhooks/ikiformCSRF-Prüfungkein Token im Request419 Page ExpiredPOST /webhooks/ikiformController$request->getContent()verifizierenStandard Webhooks204
  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

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

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

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

bootstrap/app.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
'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

ProblemUrsache und Lösung
419 im ZustellungsprotokollDie CSRF-Prüfung von Laravel. Füge den Pfad zu validateCsrfTokens(except: [...]) hinzu
$_POST ist leerBei JSON erwartet. Lies file_get_contents('php://input')
Die Signatur passt in reinem PHP niehash_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 BibliothekDas Header-Array hat falsche Keys oder Array-Werte. Nutze kleingeschriebene Keys und $request->header(), das einen String zurückgibt
Das Secret ist in Produktion nullDie Config ist gecacht, und env() wurde außerhalb einer Config-Datei aufgerufen. Lies es über config('services.ikiform.webhook_secret')

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.