Verifica las firmas de webhook

Verifica las firmas de webhook6 min de lectura

Verifica firmas de webhook en PHP y Laravel

Verifica las firmas de los webhooks de Ikiform en PHP puro con hash_hmac y hash_equals, y en Laravel con la librería de Standard Webhooks y una excepción de CSRF.

Preet SutharFundador, Ikiform

Lee el cuerpo sin procesar de php://input, toma las tres cabeceras webhook-* y calcula base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)) con el secreto whsec_ decodificado de base64 como clave. Compáralo con cada firma v1 usando hash_equals y rechaza las marcas de tiempo desfasadas más de 5 minutos. En Laravel, usa $request->getContent() y el paquete standard-webhooks/standard-webhooks, y excluye la ruta de las comprobaciones CSRF.

Dos configuraciones: un único archivo PHP que funciona en cualquier hosting con PHP 8, incluido el hosting compartido, y un controlador de Laravel. Ambos leen las mismas tres cabeceras y el mismo cuerpo sin procesar; cambian en dónde están y en qué se interpone.

Qué cambia en PHP

PHP rellena $_POST solo para las codificaciones de formulario. Ikiform envía JSON, así que $_POST está vacío y el cuerpo solo está en php://input, que son exactamente los bytes sin procesar que necesitas. Las cabeceras llegan a $_SERVER como HTTP_WEBHOOK_ID y similares. En Laravel la trampa es otra: todas las rutas de routes/web.php pasan por la verificación CSRF, y un webhook no tiene token CSRF, así que Laravel responde 419 antes de que se ejecute tu código.

Un webhook de Ikiform llegando a una ruta de Laravel con y sin excepción de CSRFroutes/web.php, middleware por defectoruta excluida del CSRFPOST/webhooks/ikiformcomprobaciónCSRFsin token en lapetición419 PageExpiredPOST/webhooks/ikiformcontrolador$request->getContent()verificarStandardWebhooks204Un webhook de Ikiform llegando a una ruta de Laravel con y sin excepción de CSRFroutes/web.php, middleware por defectoruta excluida del CSRFPOST /webhooks/ikiformcomprobación CSRFsin token en la petición419 Page ExpiredPOST /webhooks/ikiformcontrolador$request->getContent()verificarStandard Webhooks204
  1. Por defecto, un POST a una ruta de routes/web.php pasa por la comprobación CSRF de Laravel.
  2. La entrega de Ikiform no tiene token CSRF, así que Laravel responde 419 y el controlador nunca se ejecuta.
  3. Con la ruta incluida en el except de validateCsrfTokens, la petición llega al controlador.
  4. El controlador verifica el cuerpo sin procesar con la librería de Standard Webhooks y responde 204.
La firma sustituye al CSRF en esta ruta. Todas las demás rutas mantienen su protección.

PHP puro

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 se puede leer más de una vez, así que un framework o una librería que ya lo haya leído no te impide volver a leerlo.
  • hash_hmac(..., true) devuelve bytes sin procesar, que luego se codifican en base64. Sin true obtienes hex, que nunca coincide.
  • base64_decode(..., true) es estricto: un secreto dañado devuelve false en lugar de decodificarse en silencio a una clave incorrecta.
  • hash_equals es de tiempo constante. Pasa primero el valor que calculaste y después el de la petición.
  • Interpolar $body en la cadena es seguro con datos binarios en PHP, así que los caracteres no ASCII de un envío no cambian nada.
  • $_SERVER['HTTP_WEBHOOK_ID'] funciona con Apache, con nginx y PHP-FPM y con el servidor integrado, a diferencia de getallheaders() en algunas configuraciones antiguas.
  • Guarda IKIFORM_WEBHOOK_SECRET en el entorno del servidor o en un archivo fuera de la raíz web, nunca en webhook.php.

Comprobarlo con el vector de prueba

Este script pasa el vector de prueba por la función. Ejecútalo con php check-signature.php; termina con un código distinto de cero ante cualquier fallo, así que puede ejecutarse en CI tal cual o convertirse más adelante en casos de 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);

Responder primero en PHP puro

Un script de PHP puro normalmente envía su respuesta al terminar, así que el trabajo lento en webhook.php retrasa el 2xx para Ikiform, y todo lo que pase de 15 segundos cuenta como fallo y se reintenta. Con PHP-FPM, fastcgi_finish_request() envía la respuesta y deja que el script siga. Esta versión del final de webhook.php responde primero y luego registra el id de la entrega con una restricción única para que un reintento se salte:

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 solo existe con PHP-FPM, de ahí la comprobación; con mod_php de Apache el script se ejecuta hasta el final antes de enviar la respuesta. La cláusula ON CONFLICT es sintaxis de PostgreSQL y SQLite. En MySQL, usa INSERT IGNORE, que también devuelve un recuento de filas de 0 en una repetición.

Laravel

Instala la librería de Standard Webhooks con 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);

Excluye la ruta de la verificación CSRF en bootstrap/app.php:

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

Y lee el secreto a través de la configuración, para que php artisan config:cache siga funcionando:

config/services.php
'ikiform' => [
    'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],
  • $request->getContent() es el cuerpo sin procesar. No verifiques json_encode($request->all()); son los mismos datos y bytes distintos.
  • La librería busca las cabeceras por claves exactas en minúsculas, así que construye el array explícitamente como se muestra. Pasar $request->headers->all() no funciona: esos valores son arrays.
  • new Webhook($secret) quita whsec_ y decodifica la clave. Lanza WebhookVerificationException si falta una cabecera, si la marca de tiempo es antigua o futura, o si ninguna firma coincide.
  • response()->noContent() responde 204. Pon el trabajo lento en cola con un job para que la respuesta vuelva holgadamente dentro de los 15 segundos de Ikiform. Un índice único en una columna delivery_id, escrito con insertOrIgnore, hace que el job pueda recibirse dos veces sin problema.
Las rutas de routes/api.php se saltan el CSRF sin ninguna excepción y llevan el prefijo /api. Si tu app ya tiene rutas de API, pon ahí el webhook y sáltate el cambio en bootstrap/app.php.

Comprueba que funciona

  1. Ejecuta php check-signature.php. Todas las líneas empiezan por ok.
  2. Despliega detrás de una URL https: pública y añádela en Webhooks en el panel.
  3. Pon en IKIFORM_WEBHOOK_SECRET el secreto whsec_ del endpoint. En Laravel, ejecuta php artisan config:clear si la configuración está en caché.
  4. Elige Send test. El registro de entregas muestra 204, no 419, y los primeros 4 KB de cualquier cuerpo de error que haya impreso tu código.
  5. Cambia un carácter del secreto y envía otra prueba. El registro muestra 400 con el motivo.

Errores comunes

ProblemaCausa y solución
419 en el registro de entregasLa comprobación CSRF de Laravel. Añade la ruta a validateCsrfTokens(except: [...])
$_POST está vacíoEs lo esperado con JSON. Lee file_get_contents('php://input')
La firma nunca coincide en PHP puroSe llamó a hash_hmac sin true, así que devolvió hex. O la clave es toda la cadena whsec_ en lugar de sus bytes decodificados
Missing required headers de la libreríaEl array de cabeceras tiene claves incorrectas o valores que son arrays. Usa claves en minúsculas y $request->header(), que devuelve una cadena
El secreto es null en producciónLa configuración está en caché y se llamó a env() fuera de un archivo de configuración. Léelo con config('services.ikiform.webhook_secret')

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.