Vérifier les signatures de webhook

Vérifier les signatures de webhook6 min de lecture

Vérifier les signatures de webhook en PHP et Laravel

Vérifiez les signatures des webhooks Ikiform en PHP simple avec hash_hmac et hash_equals, et dans Laravel avec la bibliothèque Standard Webhooks et une exception CSRF.

Preet SutharFondateur, Ikiform

Lisez le corps brut depuis php://input, récupérez les trois en-têtes webhook-*, et calculez base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)) avec le secret whsec_ décodé en base64 comme clé. Comparez-le à chaque signature v1 avec hash_equals, et rejetez les timestamps décalés de plus de 5 minutes. Dans Laravel, utilisez $request->getContent(), le package standard-webhooks/standard-webhooks, et excluez la route des vérifications CSRF.

Deux configurations : un seul fichier PHP qui fonctionne chez n'importe quel hébergeur avec PHP 8, y compris en hébergement mutualisé, et un contrôleur Laravel. Les deux lisent les trois mêmes en-têtes et le même corps brut ; ils diffèrent par l'endroit où ceux-ci se trouvent et par ce qui fait obstacle.

Ce qui diffère en PHP

PHP ne remplit $_POST que pour les encodages de formulaire. Ikiform envoie du JSON, donc $_POST est vide et le corps se trouve uniquement dans php://input, qui contient exactement les octets bruts dont vous avez besoin. Les en-têtes arrivent dans $_SERVER sous la forme HTTP_WEBHOOK_ID et ainsi de suite. Dans Laravel, le piège est différent : chaque route de routes/web.php passe par la vérification CSRF, et un webhook n'a pas de jeton CSRF, donc Laravel répond 419 avant l'exécution de votre code.

Un webhook Ikiform qui atteint une route Laravel avec et sans exception CSRFroutes/web.php, middleware par défautroute exclue du CSRFPOST/webhooks/ikiformvérificationCSRFpas de jetondans larequête419 PageExpiredPOST/webhooks/ikiformcontrôleur$request->getContent()vérifierStandardWebhooks204Un webhook Ikiform qui atteint une route Laravel avec et sans exception CSRFroutes/web.php, middleware par défautroute exclue du CSRFPOST /webhooks/ikiformvérification CSRFpas de jeton dans la requête419 Page ExpiredPOST /webhooks/ikiformcontrôleur$request->getContent()vérifierStandard Webhooks204
  1. Par défaut, un POST vers une route de routes/web.php passe par la vérification CSRF de Laravel.
  2. La livraison d'Ikiform n'a pas de jeton CSRF, donc Laravel répond 419 et le contrôleur ne s'exécute jamais.
  3. Avec le chemin listé dans le except de validateCsrfTokens, la requête atteint le contrôleur.
  4. Le contrôleur vérifie le corps brut avec la bibliothèque Standard Webhooks et répond 204.
La signature remplace le CSRF pour cette route. Toutes les autres routes gardent leur protection.

PHP simple

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 peut être lu plusieurs fois, donc un framework ou une bibliothèque qui l'a déjà lu ne vous empêche pas de le relire.
  • hash_hmac(..., true) renvoie des octets bruts, ensuite encodés en base64. Sans true, vous obtenez de l'hexadécimal, qui ne correspond jamais.
  • base64_decode(..., true) est strict : un secret endommagé renvoie false au lieu d'être décodé silencieusement en une mauvaise clé.
  • hash_equals est en temps constant. Passez d'abord la valeur que vous avez calculée, puis celle de la requête.
  • Interpoler $body dans la chaîne est sans risque pour les données binaires en PHP, donc les caractères non ASCII d'une soumission ne changent rien.
  • $_SERVER['HTTP_WEBHOOK_ID'] fonctionne sous Apache, nginx avec PHP-FPM et le serveur intégré, contrairement à getallheaders() sur certaines configurations anciennes.
  • Gardez IKIFORM_WEBHOOK_SECRET dans l'environnement du serveur ou dans un fichier hors de la racine web, jamais dans webhook.php.

Le vérifier avec le vecteur de test

Ce script passe le vecteur de test dans la fonction. Lancez-le avec php check-signature.php ; il se termine avec un code non nul en cas d'échec, donc il peut tourner en CI tel quel ou devenir plus tard des cas PHPUnit :

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

Répondre d'abord en PHP simple

Un script PHP simple envoie normalement sa réponse quand il se termine, donc le travail lent dans webhook.php retarde le 2xx destiné à Ikiform, et tout ce qui dépasse 15 secondes compte comme un échec et donne lieu à une nouvelle tentative. Sous PHP-FPM, fastcgi_finish_request() envoie la réponse et laisse le script continuer. Cette version de la fin de webhook.php répond d'abord, puis enregistre l'identifiant de livraison avec une contrainte d'unicité pour qu'une nouvelle tentative soit ignorée :

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 n'existe que sous PHP-FPM, d'où la vérification ; sous le mod_php d'Apache, le script s'exécute jusqu'au bout avant que la réponse ne parte. La clause ON CONFLICT est une syntaxe PostgreSQL et SQLite. Sur MySQL, utilisez INSERT IGNORE, qui renvoie aussi un nombre de lignes de 0 pour une répétition.

Laravel

Installez la bibliothèque Standard Webhooks avec 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);

Excluez le chemin de la vérification CSRF dans bootstrap/app.php :

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

Et lisez le secret via la config, pour que php artisan config:cache continue de fonctionner :

config/services.php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
  • $request->getContent() est le corps brut. Ne vérifiez pas json_encode($request->all()) ; ce sont les mêmes données mais des octets différents.
  • La bibliothèque recherche les en-têtes par clés exactes en minuscules, donc construisez le tableau explicitement comme indiqué. Passer $request->headers->all() ne fonctionne pas : ces valeurs sont des tableaux.
  • new Webhook($secret) retire whsec_ et décode la clé. Il lève WebhookVerificationException pour un en-tête manquant, un timestamp trop ancien ou dans le futur, ou l'absence de signature correspondante.
  • response()->noContent() répond 204. Mettez le travail lent en file d'attente avec un job pour que la réponse parte bien avant les 15 secondes d'Ikiform. Un index unique sur une colonne delivery_id, écrit avec insertOrIgnore, permet au job de recevoir deux fois la même livraison sans risque.
Les routes de routes/api.php échappent au CSRF sans aucune exception et reçoivent un préfixe /api. Si votre app a déjà des routes d'API, placez-y plutôt le webhook et passez la modification de bootstrap/app.php.

Vérifiez que tout fonctionne

  1. Lancez php check-signature.php. Chaque ligne commence par ok.
  2. Déployez derrière une URL https: publique et ajoutez-la sous Webhooks dans le tableau de bord.
  3. Définissez IKIFORM_WEBHOOK_SECRET avec le secret whsec_ de l'endpoint. Dans Laravel, lancez php artisan config:clear si la config est en cache.
  4. Choisissez Send test. Le journal de livraison affiche 204, pas 419, ainsi que les 4 premiers Ko de tout corps d'erreur affiché par votre code.
  5. Modifiez un caractère du secret et envoyez un autre test. Le journal affiche 400 avec la raison.

Pièges

ProblèmeCause et solution
419 dans le journal de livraisonLa vérification CSRF de Laravel. Ajoutez le chemin à validateCsrfTokens(except: [...])
$_POST est videC'est normal pour du JSON. Lisez file_get_contents('php://input')
La signature ne correspond jamais en PHP simplehash_hmac a été appelé sans true, donc il a renvoyé de l'hexadécimal. Ou la clé est toute la chaîne whsec_ au lieu de ses octets décodés
Missing required headers renvoyé par la bibliothèqueLe tableau d'en-têtes a de mauvaises clés ou des valeurs en tableau. Utilisez des clés en minuscules et $request->header(), qui renvoie une chaîne
Le secret vaut null en productionLa config est en cache et env() a été appelé hors d'un fichier de config. Lisez-le via config('services.ikiform.webhook_secret')

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.