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

Publicado el 2026-10-25 · https://ikiform.com/es/guides/webhook-signature-verification/php

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.

Diagrama: Un webhook de Ikiform llegando a una ruta de Laravel con y sin excepción de CSRF

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

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

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

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

`bootstrap/app.php`:

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

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

| 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

### ¿Cómo obtengo el cuerpo POST sin procesar en PHP?

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.

### ¿Por qué Laravel devuelve 419 para mi webhook?

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.

### ¿Es seguro hash_equals para comparar firmas de webhook?

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.

### ¿Qué paquete de Composer verifica Standard Webhooks en PHP?

standard-webhooks/standard-webhooks. new StandardWebhooks\Webhook($secret) recibe el secreto whsec_, y verify($body, $headers) devuelve el payload decodificado o lanza WebhookVerificationException.

### ¿Por qué mi resultado de hash_hmac no coincide con la cabecera webhook-signature?

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.

## Más guías

- [Verifica las firmas de webhook](https://ikiform.com/es/guides/webhook-signature-verification.md): Comprueba que un webhook de formulario viene de verdad de Ikiform: el esquema HMAC-SHA256 de Standard Webhooks, un vector de prueba, cuerpos sin procesar, marcas de tiempo y rotación de secretos.
- [Ruby](https://ikiform.com/es/guides/webhook-signature-verification/ruby.md): Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.
- [Python](https://ikiform.com/es/guides/webhook-signature-verification/python.md): Verifica las firmas de los webhooks de Ikiform en Flask, FastAPI y Django, con el paquete standardwebhooks o la librería estándar, más un test de pytest.
- [Node.js](https://ikiform.com/es/guides/webhook-signature-verification/nodejs.md): Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
- [Go net/http](https://ikiform.com/es/guides/webhook-signature-verification/go.md): Verifica las firmas de los webhooks de Ikiform en Go con net/http: lee el cuerpo una vez, compruébalo con crypto/hmac, pruébalo o usa la librería de Standard Webhooks.
