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.
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.
- Par défaut, un POST vers une route de routes/web.php passe par la vérification CSRF de Laravel.
- La livraison d'Ikiform n'a pas de jeton CSRF, donc Laravel répond 419 et le contrôleur ne s'exécute jamais.
- Avec le chemin listé dans le except de validateCsrfTokens, la requête atteint le contrôleur.
- Le contrôleur vérifie le corps brut avec la bibliothèque Standard Webhooks et répond 204.
PHP simple
<?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://inputpeut ê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. Sanstrue, vous obtenez de l'hexadécimal, qui ne correspond jamais.base64_decode(..., true)est strict : un secret endommagé renvoiefalseau lieu d'être décodé silencieusement en une mauvaise clé.hash_equalsest en temps constant. Passez d'abord la valeur que vous avez calculée, puis celle de la requête.- Interpoler
$bodydans 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_SECRETdans l'environnement du serveur ou dans un fichier hors de la racine web, jamais danswebhook.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 :
<?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 :
$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 :
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);Excluez le chemin de la vérification CSRF dans 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 :
'ikiform' => [
'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],$request->getContent()est le corps brut. Ne vérifiez pasjson_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)retirewhsec_et décode la clé. Il lèveWebhookVerificationExceptionpour un en-tête manquant, un timestamp trop ancien ou dans le futur, ou l'absence de signature correspondante.response()->noContent()répond204. 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 colonnedelivery_id, écrit avecinsertOrIgnore, permet au job de recevoir deux fois la même livraison sans risque.
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
- Lancez
php check-signature.php. Chaque ligne commence parok. - Déployez derrière une URL
https:publique et ajoutez-la sous Webhooks dans le tableau de bord. - Définissez
IKIFORM_WEBHOOK_SECRETavec le secretwhsec_de l'endpoint. Dans Laravel, lancezphp artisan config:clearsi la config est en cache. - Choisissez Send test. Le journal de livraison affiche
204, pas419, ainsi que les 4 premiers Ko de tout corps d'erreur affiché par votre code. - Modifiez un caractère du secret et envoyez un autre test. Le journal affiche
400avec la raison.
Pièges
| Problème | Cause et solution |
|---|---|
419 dans le journal de livraison | La vérification CSRF de Laravel. Ajoutez le chemin à validateCsrfTokens(except: [...]) |
$_POST est vide | C'est normal pour du JSON. Lisez file_get_contents('php://input') |
| La signature ne correspond jamais en PHP simple | hash_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èque | Le 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 production | La config est en cache et env() a été appelé hors d'un fichier de config. Lisez-le via config('services.ikiform.webhook_secret') |
Questions
Lisez file_get_contents('php://input'). Pour une requête JSON, $_POST est vide, et php://input contient le corps exactement tel qu'il a été envoyé, ce que couvre la signature.
Les routes de routes/web.php sont protégées contre le CSRF, et un webhook ne peut pas envoyer de jeton CSRF. Excluez le chemin avec validateCsrfTokens(except: [...]) dans bootstrap/app.php, ou définissez la route dans routes/api.php.
Oui. C'est la comparaison de chaînes en temps constant de PHP. Utilisez-la au lieu de === pour que le temps écoulé ne révèle pas quelle part d'une signature falsifiée était juste.
standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) prend le secret whsec_, et verify($body, $headers) renvoie le payload décodé ou lève WebhookVerificationException.
Par défaut, hash_hmac renvoie de l'hexadécimal. Passez true en quatrième argument pour obtenir des octets bruts, puis appliquez-leur base64_encode. Vérifiez aussi que la clé est la partie décodée en base64 après whsec_, et non toute la chaîne du secret.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.