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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/webhook-signature-verification/php

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.

Schéma : Un webhook Ikiform qui atteint une route Laravel avec et sans exception CSRF

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
<?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` 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](https://ikiform.com/fr/guides/webhook-signature-verification.md#test-vector) 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
<?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`:

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

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

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

`bootstrap/app.php`:

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

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

### Comment obtenir le corps POST brut en PHP ?

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.

### Pourquoi Laravel renvoie-t-il 419 pour mon webhook ?

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.

### hash_equals est-il sûr pour comparer des signatures de webhook ?

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.

### Quel package Composer vérifie Standard Webhooks en PHP ?

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.

### Pourquoi mon résultat hash_hmac ne correspond-il pas à l'en-tête webhook-signature ?

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.

## Autres guides

- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [Ruby](https://ikiform.com/fr/guides/webhook-signature-verification/ruby.md): Vérifiez les signatures des webhooks Ikiform dans Sinatra avec OpenSSL et un test Minitest, et dans Rails avec la gem standardwebhooks et request.raw_post.
- [Python](https://ikiform.com/fr/guides/webhook-signature-verification/python.md): Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.
- [Node.js](https://ikiform.com/fr/guides/webhook-signature-verification/nodejs.md): Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.
- [Go net/http](https://ikiform.com/fr/guides/webhook-signature-verification/go.md): Vérifiez les signatures des webhooks Ikiform en Go avec net/http : lisez le corps une seule fois, vérifiez-le avec crypto/hmac, testez-le, ou utilisez la bibliothèque Standard Webhooks.
