Guías13 min de lectura
Verifica las firmas de los webhooks de formularios
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.
Recalcula tú mismo la firma y compárala con la de la petición. Ikiform sigue Standard Webhooks: la firma es un HMAC-SHA256 en base64 de webhook-id, webhook-timestamp y el cuerpo sin procesar unidos por puntos, con la parte decodificada de base64 de tu secreto whsec_ como clave, y se envía como v1,<signature>. Acepta la petición solo si una firma v1 coincide en una comparación en tiempo constante y la marca de tiempo está dentro de 5 minutos.
Un endpoint de webhook es una URL pública que acepta peticiones POST de cualquiera. Sin comprobar la firma, cualquiera que descubra la URL puede meter envíos falsos en tu CRM, tu base de datos o el chat de tu equipo. La comprobación son unas pocas líneas de código, y escribir esas líneas exactamente bien es de lo que trata esta guía.
Cómo funciona la firma
Ikiform y tu servidor comparten un secreto, que se muestra una sola vez al añadir el endpoint. Antes de cada intento de entrega, Ikiform calcula un hash de la petición con ese secreto y pone el resultado en una cabecera. Tu servidor repite el cálculo con lo que ha recibido. Solo alguien con el secreto puede producir un valor que coincida, y cualquier cambio en el cuerpo, el id o la marca de tiempo lo rompe.
- Ikiform serializa el evento como JSON.
- Une con puntos el id de la entrega, la marca de tiempo Unix del intento y el cuerpo.
- Calcula un HMAC-SHA256 de esa cadena con cada secreto activo del endpoint.
- Envía cada resultado, codificado en base64 y con el prefijo v1, en la cabecera webhook-signature.
- Tu handler lee el cuerpo sin procesar y las tres cabeceras antes de analizar nada.
- Calcula el mismo HMAC con su copia del secreto.
- Compara en tiempo constante con cada firma v1 y comprueba que la marca de tiempo esté dentro de 5 minutos.
- Si coincide, responde 2xx y procesa el evento. Si no, responde 400 y no hace nada.
El esquema, con exactitud
Ikiform usa Standard Webhooks, una especificación abierta que comparten varios proveedores de webhooks. Cada entrega lleva tres cabeceras:
| Cabecera | Valor | Notas |
|---|---|---|
webhook-id | dlv_... | El id de la entrega. Es el mismo en cada reintento, así que también sirve como clave de idempotencia |
webhook-timestamp | Segundos Unix, como 1792300000 | Cuándo se firmó este intento. Cambia en cada reintento |
webhook-signature | v1, más base64 | Uno o más valores separados por espacios. Dos mientras se rota un secreto |
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
raw body: {"id":"dlv_01k7...","type":"endpoint.test",...}
signed content = webhook-id + "." + webhook-timestamp + "." + raw body
key = base64-decode(secret without "whsec_")
signature = base64(HMAC-SHA256(key, signed content))
header = "v1," + signaturePara verificar una entrega, en este orden:
- Lee el cuerpo sin procesar como bytes o como una cadena exacta. No lo analices todavía.
- Lee las tres cabeceras. Si falta alguna, rechaza la petición.
- Comprueba que la marca de tiempo sea un entero a menos de 5 minutos de tu reloj, en cualquier sentido. Si no, rechaza la petición.
- Quita
whsec_de tu secreto y decodifica el resto de base64. Esos 32 bytes son la clave del HMAC, no la cadena en sí. - Calcula el HMAC-SHA256 de
{webhook-id}.{webhook-timestamp}.{raw body}y codifícalo en base64 con el alfabeto estándar y relleno. - Divide
webhook-signaturepor los espacios. Divide cada entrada por la primera coma y salta todo lo que no tenga la versiónv1. - Compara cada valor
v1con el tuyo en tiempo constante. Si alguno coincide, la entrega es auténtica. - Solo entonces analiza el JSON y actúa en consecuencia.
Un vector de prueba
Antes de desplegar un handler, demuestra que calcula el valor correcto. Estas entradas son el cuerpo real de una entrega endpoint.test firmado con un secreto inventado. Tu código tiene que producir exactamente la firma de la última fila. Cada guía por lenguaje incluye un test unitario que las usa.
| Entrada | Valor |
|---|---|
| Secreto | whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw= |
webhook-id | dlv_01k7v2d4n8r2p6s0w3y5b7c9e1 |
webhook-timestamp | 1792300000 |
| Cuerpo sin procesar | Los bytes exactos del script de abajo, sin espacios ni salto de línea |
webhook-signature esperado | v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= |
Puedes comprobarlo sin escribir código. Esto se ejecuta en una terminal de macOS o Linux e imprime la parte en base64 de la firma:
SECRET='whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw='
ID='dlv_01k7v2d4n8r2p6s0w3y5b7c9e1'
TS='1792300000'
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."}}'
KEY=$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary \
| base64Vale la pena un test para cada uno de estos dos casos, porque son los dos fallos reales más comunes:
| Cambio | Firma | ¿Debe aceptarla tu handler? |
|---|---|---|
| El mismo cuerpo formateado con sangría de dos espacios | v1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY= | No. Mismo JSON, bytes distintos |
webhook-timestamp un segundo después | v1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks= | No. La marca de tiempo está firmada |
| Firmada con un segundo secreto durante una rotación | v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak= | Sí, si la cabecera también tiene un valor que coincide con tu secreto |
Durante la rotación de un secreto, la cabecera lleva los dos valores. Tu handler, con cualquiera de los dos secretos, debe aceptar esto:
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=Formas de verificar
| Enfoque | Punto fuerte | Inconvenientes |
|---|---|---|
verifyWebhook de @ikiform/sdk/webhooks | Una sola llamada que lo comprueba todo y devuelve un evento tipado. Funciona en Node, Workers, Deno y Bun | Solo JavaScript y TypeScript |
| Una librería de Standard Webhooks | Código mantenido para Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# y Elixir | Una dependencia más. Revisa cómo quiere cada una que le pases las cabeceras; algunas distinguen mayúsculas y minúsculas |
| Tu propio código HMAC | Sin dependencias, unas 20 líneas, y puedes probar cada rama | Fácil de equivocarse en detalles sutiles: comparación de cadenas, bytes de clave incorrectos o un cuerpo ya analizado |
| Una ruta secreta o un token en la query | Sencillo | Se filtra por logs, proxies y herramientas de seguimiento de errores, no demuestra nada sobre el cuerpo y no puede caducar. No es un sustituto |
Ikiform firma cada entrega, y la firma es la forma admitida de autenticarla. Su documentación no publica una lista fija de direcciones IP de envío, así que una lista de IP permitidas no es una comprobación en la que puedas confiar.
Verifica en tu framework
En los stacks de JavaScript, el SDK hace el trabajo. Lo que cambia entre frameworks es dónde vive la ruta y cómo obtener el cuerpo antes de que algo lo analice. Elige el tuyo:
Un route handler del App Router. request.text() es el cuerpo sin procesar; llamar antes a request.json() lo perdería. La guía de servidor propio añade deduplicación y after().
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, request.headers.get("webhook-id"));
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}Un endpoint con un export POST. Necesita renderizado bajo demanda, así que mantén prerender = false y un adaptador. getSecret de astro:env/server lee IKIFORM_WEBHOOK_SECRET en tiempo de ejecución y solo en el servidor; import.meta.env lo incrustaría en el build.
import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: getSecret("IKIFORM_WEBHOOK_SECRET") ?? "",
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};Una server route: la misma ruta basada en archivos, con una función server.handlers.POST que recibe el Request estándar.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/webhooks/ikiform")({
server: {
handlers: {
POST: async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
},
},
},
});Una resource route: un módulo con un action y sin export por defecto, registrado en routes.ts. Responde directamente a Ikiform en lugar de renderizar una página.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("webhooks/ikiform", "routes/webhooks.ikiform.ts"),
] satisfies RouteConfig;import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/webhooks.ikiform";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") return new Response(null, { status: 405 });
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}Un endpoint +server.ts. La comprobación CSRF integrada de SvelteKit solo bloquea los content types de formularios entre sitios, e Ikiform envía application/json, así que no hace falta cambiar la configuración. Declara IKIFORM_WEBHOOK_SECRET en src/env.ts con defineEnvVars para que $app/env/private pueda leerlo.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { IKIFORM_WEBHOOK_SECRET } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
const payload = await request.text();
try {
const event = await verifyWebhook({ payload, headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
console.info(event.type, event.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
};Una server route de Nitro. readRawBody devuelve el cuerpo como cadena antes de que nada lo analice. Define NUXT_IKIFORM_WEBHOOK_SECRET y declara ikiformWebhookSecret en runtimeConfig dentro de nuxt.config.ts.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const payload = (await readRawBody(event, "utf8")) ?? "";
const { ikiformWebhookSecret } = useRuntimeConfig(event);
try {
const delivery = await verifyWebhook({
payload,
headers: getRequestHeaders(event),
secret: ikiformWebhookSecret,
});
console.info(delivery.type, delivery.id);
setResponseStatus(event, 204);
return null;
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw createError({ status: 400, message: error.reason });
}
throw error;
}
});Cada handler responde 204 si todo va bien y 400 con el motivo del fallo en caso contrario. Haz el trabajo lento después de responder; Ikiform espera 15 segundos antes de contar un intento como fallido.
Por qué importa el cuerpo sin procesar
La firma cubre bytes, no significado. {"a":1} y { "a": 1 } son el mismo JSON y bytes distintos, así que un cuerpo que se ha analizado y vuelto a serializar casi nunca coincide. Lo mismo vale para cualquier cosa que toque el cuerpo al entrar: un middleware que analiza JSON, un proxy que lo recodifica o un framework que te da un objeto en lugar de una cadena.
Lee el cuerpo una vez, como bytes o como cadena, verifícalo y luego analiza esa misma cadena. Cada guía por lenguaje muestra dónde está el cuerpo sin procesar en sus frameworks, y la guía de servidor propio tiene una tabla para los más comunes de Node y Python.
Compara en tiempo constante
Una comparación de cadenas normal se detiene en el primer carácter distinto. En teoría, un atacante que pueda medir el tiempo de miles de peticiones averigua cuántos caracteres iniciales de un intento son correctos y construye una firma válida carácter a carácter. Una comparación en tiempo constante tarda lo mismo coincida lo que coincida. Todos los lenguajes tienen una:
| Lenguaje | Comparación en tiempo constante |
|---|---|
| Node.js | crypto.timingSafeEqual(a, b), que lanza un error si las longitudes difieren, así que comprueba antes la longitud |
| Python | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) |
| PHP | hash_equals($known, $user) |
| Ruby | OpenSSL.secure_compare(a, b), o ActiveSupport::SecurityUtils.secure_compare en Rails |
Marcas de tiempo y ataques de repetición
Una petición válida capturada una vez sigue siendo válida para siempre a menos que algo la haga caducar. La marca de tiempo firmada es ese algo: rechaza cualquier entrega a más de 5 minutos de tu reloj, en cualquier sentido. Como Ikiform vuelve a firmar cada intento con una marca de tiempo nueva, un reintento tres días después sigue pasando; solo falla una petición antigua copiada.
Dentro de esos 5 minutos sigue siendo posible un ataque de repetición, y también un duplicado legítimo: Ikiform reintenta cuando no recibe un 2xx a tiempo, aunque tu handler haya terminado. Guarda webhook-id con una restricción única y salta los ids que ya hayas procesado. Eso cierra ambos casos.
timestamp_too_old o timestamp_too_new. Las plataformas gestionadas suelen mantener los relojes sincronizados; en tu propia VM, usa NTP.Rotar el secreto
Rota el secreto cuando se vaya alguien que lo conocía, cuando pueda haber acabado en un log o en un commit, o de forma periódica. Roll signing secret en la página del endpoint crea uno nuevo, y durante 24 horas cada entrega se firma con los dos:
- Antes de la rotación, cada entrega lleva una firma, hecha con el secreto antiguo.
- Eliges Roll signing secret y copias el secreto nuevo.
- Durante las siguientes 24 horas, cada entrega lleva dos firmas, así que un servidor con cualquiera de los dos secretos la acepta. Despliega el secreto nuevo en esta ventana.
- Pasadas 24 horas, las entregas solo llevan la firma nueva, y un servidor que aún tenga el secreto antiguo las rechaza.
Si el secreto antiguo sigue desplegado pasadas 24 horas, todas las entregas fallan. Ikiform reintenta cada una 8 veces a lo largo de unos tres días, así que, en cuanto despliegues el secreto correcto, usa Retry en el registro de entregas para las que ya se dieron por perdidas.
Después de pasar la comprobación
Una firma válida demuestra que la petición vino de Ikiform y que no se modificó. No demuestra que el contenido sea seguro ni nuevo. Tres hábitos completan el trabajo:
- Salta las repeticiones. Guarda
webhook-id, o eliddel payload, que tiene el mismo valor, con una restricción única. Un reintento de una entrega que ya procesaste no hace nada. - Responde primero, trabaja después. Devuelve
2xxen cuanto el evento esté guardado o en cola. Ikiform cuenta como fallo todo lo que tarde más de 15 segundos y reintenta, lo que vuelve a traer el problema de los duplicados. - Trata los campos como no fiables.
data.fieldscontiene lo que un visitante escribió en un formulario público. Escápalo antes de mostrarlo en HTML, en un mensaje de chat o en un email, y nunca lo pases a una shell ni a una consulta sin parametrizar.
Pruebas antes de salir a producción
Ikiform solo entrega a URLs https: públicas en el puerto 443, así que localhost no puede recibir un webhook. Ejecuta un túnel como cloudflared tunnel --url http://localhost:3000 y añade la URL que imprime como un segundo endpoint mientras trabajas. Send test en la página del endpoint envía un evento endpoint.test una vez, sin reintentos, firmado exactamente igual que una entrega real. Elimina el endpoint de prueba cuando termines; tiene su propio secreto, así que nada más cambia.
Si una entrega falla y no ves por qué, copia sus cabeceras y su cuerpo de tus logs en el verificador de firmas. Ejecuta la misma comprobación en el navegador y te dice qué paso falló.
Cinco formas en que falla la verificación
| Error | Síntoma | Solución |
|---|---|---|
Usar toda la cadena whsec_... como clave | no_matching_signature en todas las entregas | Quita whsec_ y decodifica de base64. La clave son 32 bytes |
| Verificar un cuerpo ya analizado | no_matching_signature, a veces solo con algunos payloads | Verifica el cuerpo sin procesar; analízalo después |
| Comprobar solo la primera firma | Fallos que empiezan justo después de una rotación | Recorre cada entrada v1 separada por espacios |
| Hex en lugar de base64 | no_matching_signature | Standard Webhooks usa base64 estándar con relleno, no hex ni base64url |
| Buscar las cabeceras distinguiendo mayúsculas | missing_headers detrás de algunos proxies | Los nombres de cabecera no distinguen mayúsculas y minúsculas. Ikiform los envía en minúsculas; búscalos sin distinguir mayúsculas |
No hay integraciones de un clic. Cada herramienta se conecta mediante un webhook firmado, directamente o a través de una herramienta de automatización como Zapier o n8n. Un espacio de trabajo puede tener 3 endpoints, cada uno con su propio secreto. El payload completo, el calendario de reintentos y el registro de entregas están en Webhooks.
Elige tu caso
Cada página por lenguaje tiene handlers idiomáticos, una opción con librería y un test unitario que ejecuta el vector de prueba de esta página.
- Node.jsVerifica 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.
- PythonVerifica 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.
- Go net/httpVerifica 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.
- PHPVerifica 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.
- RubyVerifica 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.
Comprueba que funciona
Hazlo primero en un endpoint de staging y luego otra vez en producción. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para salir a producción, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
- Pasa el vector de prueba por tu código: debe producir la firma esperada, aceptar la cabecera con dos firmas y rechazar el cuerpo formateado.
- En el panel, abre Webhooks, añade la URL
https:pública de tu endpoint y copia el secretowhsec_en el entorno de tu servidor. - Elige Send test en la página del endpoint. El registro de entregas muestra tu
204y el evento llega a tus logs. - Cambia un carácter del secreto en tu servidor y envía otra prueba. El registro muestra
400conno_matching_signature. - Envía el formulario de verdad y comprueba que la entrega
submission.createdtiene éxito.
Solución de problemas
| Problema | Qué hacer |
|---|---|
400 no_matching_signature | Secreto incorrecto, un cuerpo ya analizado o una clave que no se decodificó de base64. Pasa el vector de prueba por el mismo código para ver cuál es. |
400 timestamp_too_old o timestamp_too_new | El reloj del servidor va desfasado, o estás reenviando a mano una petición guardada. Sincroniza el reloj y usa Retry en el registro de entregas, que vuelve a firmar. |
400 missing_headers | Un proxy o el framework eliminó las cabeceras webhook-*, o la petición no vino de Ikiform. Registra en el log cada nombre de cabecera que ve tu handler. |
| Funciona con eventos de prueba, falla con envíos reales | Los payloads reales contienen caracteres no ASCII y textos más largos. Tu código decodifica o recodifica el cuerpo antes de verificarlo. Verifica los bytes exactos. |
| Todas las entregas fallan justo después de rotar el secreto | El handler solo comprueba la primera firma, o pasaron las 24 horas con el secreto antiguo desplegado. Comprueba cada entrada v1 y despliega el secreto nuevo. |
| El registro de entregas muestra un timeout, pero tu handler se ejecutó | Ikiform espera 15 segundos. Responde primero, haz el trabajo lento después y salta los valores de webhook-id repetidos. |
Preguntas
Recalcula el HMAC-SHA256 del id del webhook, la marca de tiempo y el cuerpo sin procesar unidos por puntos, usando como clave la parte decodificada de base64 de tu secreto whsec_. Codifica el resultado en base64 y compáralo en tiempo constante con cada valor v1 de la cabecera webhook-signature.
Casi siempre es una de tres cosas: el cuerpo se analizó y se volvió a serializar antes de verificarlo, se usó toda la cadena whsec_ como clave en lugar de sus bytes decodificados de base64, o el secreto es de otro endpoint.
Una especificación abierta para firmar webhooks con HMAC-SHA256 y las cabeceras webhook-id, webhook-timestamp y webhook-signature. Ikiform la sigue, así que cualquier librería de Standard Webhooks puede verificar las entregas de Ikiform.
Si no, una petición capturada seguiría siendo válida para siempre. Rechazar todo lo que se aleje más de 5 minutos de tu reloj frena la repetición de peticiones antiguas, e Ikiform firma cada reintento con una marca de tiempo nueva, así que los reintentos reales siguen pasando.
Durante 24 horas cada entrega se firma con el secreto antiguo y con el nuevo, así que puedes desplegar el nuevo sin perder eventos. Después, solo funciona el secreto nuevo.
Cualquiera de los dos sirve para Ikiform; cualquier estado distinto de 2xx cuenta como fallo y se reintenta. No devuelvas 410, que desactiva el endpoint de inmediato.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.