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.
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.
- Por defecto, un POST a una ruta de routes/web.php pasa por la comprobación CSRF de Laravel.
- La entrega de Ikiform no tiene token CSRF, así que Laravel responde 419 y el controlador nunca se ejecuta.
- Con la ruta incluida en el except de validateCsrfTokens, la petición llega al controlador.
- El controlador verifica el cuerpo sin procesar con la librería de Standard Webhooks y responde 204.
PHP puro
<?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://inputse 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. Sintrueobtienes hex, que nunca coincide.base64_decode(..., true)es estricto: un secreto dañado devuelvefalseen lugar de decodificarse en silencio a una clave incorrecta.hash_equalses de tiempo constante. Pasa primero el valor que calculaste y después el de la petición.- Interpolar
$bodyen 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 degetallheaders()en algunas configuraciones antiguas.- Guarda
IKIFORM_WEBHOOK_SECRETen el entorno del servidor o en un archivo fuera de la raíz web, nunca enwebhook.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:
<?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:
$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:
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);Excluye la ruta de la verificación CSRF en 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:
'ikiform' => [
'webhook_secret' => env('IKIFORM_WEBHOOK_SECRET'),
],$request->getContent()es el cuerpo sin procesar. No verifiquesjson_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)quitawhsec_y decodifica la clave. LanzaWebhookVerificationExceptionsi falta una cabecera, si la marca de tiempo es antigua o futura, o si ninguna firma coincide.response()->noContent()responde204. 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 columnadelivery_id, escrito coninsertOrIgnore, hace que el job pueda recibirse dos veces sin problema.
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
- Ejecuta
php check-signature.php. Todas las líneas empiezan porok. - Despliega detrás de una URL
https:pública y añádela en Webhooks en el panel. - Pon en
IKIFORM_WEBHOOK_SECRETel secretowhsec_del endpoint. En Laravel, ejecutaphp artisan config:clearsi la configuración está en caché. - Elige Send test. El registro de entregas muestra
204, no419, y los primeros 4 KB de cualquier cuerpo de error que haya impreso tu código. - Cambia un carácter del secreto y envía otra prueba. El registro muestra
400con el motivo.
Errores comunes
| Problema | Causa y solución |
|---|---|
419 en el registro de entregas | La comprobación CSRF de Laravel. Añade la ruta a validateCsrfTokens(except: [...]) |
$_POST está vacío | Es lo esperado con JSON. Lee file_get_contents('php://input') |
| La firma nunca coincide en PHP puro | Se 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ía | El 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ón | La 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
Lee file_get_contents('php://input'). En una petición JSON, $_POST está vacío, y php://input contiene el cuerpo exactamente como se envió, que es lo que cubre la firma.
Las rutas de routes/web.php están protegidas con CSRF, y un webhook no puede enviar un token CSRF. Excluye la ruta con validateCsrfTokens(except: [...]) en bootstrap/app.php, o define la ruta en routes/api.php.
Sí. Es la comparación de cadenas en tiempo constante de PHP. Úsala en lugar de === para que el tiempo que tarda no revele qué parte de una firma falsificada era correcta.
standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) recibe el secreto whsec_, y verify($body, $headers) devuelve el payload decodificado o lanza WebhookVerificationException.
Por defecto, hash_hmac devuelve hex. Pasa true como cuarto argumento para obtener bytes sin procesar y luego aplícales base64_encode. Comprueba también que la clave sea la parte decodificada de base64 después de whsec_, no la cadena entera del secreto.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.