Guías

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.

Preet SutharFundador, Ikiform

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.

Cómo firma Ikiform una entrega de webhook y cómo la comprueba tu servidorIkiform, en cada intentotu handlerCuerpoJSONsubmission.createdid.timestamp.bodyel contenidofirmadoHMAC-SHA256con cadasecreto activowebhook-signaturev1,<base64>cuerpo sinprocesar +cabecerasantes deanalizar nadamismo HMACcon tu copiadel secretocomparaciónen tiempoconstantealguna v1coincide, marcareciente2xxluego analiza yprocesa400no se procesanadasin coincidenciaCómo firma Ikiform una entrega de webhook y cómo la comprueba tu servidorIkiform, en cada intentotu handlerCuerpo JSONsubmission.createdid.timestamp.bodyel contenidofirmadoHMAC-SHA256con cada secretoactivowebhook-signaturev1,<base64>cuerpo sinprocesar +cabecerasantes de analizarnadamismo HMACcon tu copia delsecretocomparación entiempoconstantealguna v1 coincide,marca reciente2xxluego analiza yprocesa400no seprocesanadasin coincidencia
  1. Ikiform serializa el evento como JSON.
  2. Une con puntos el id de la entrega, la marca de tiempo Unix del intento y el cuerpo.
  3. Calcula un HMAC-SHA256 de esa cadena con cada secreto activo del endpoint.
  4. Envía cada resultado, codificado en base64 y con el prefijo v1, en la cabecera webhook-signature.
  5. Tu handler lee el cuerpo sin procesar y las tres cabeceras antes de analizar nada.
  6. Calcula el mismo HMAC con su copia del secreto.
  7. Compara en tiempo constante con cada firma v1 y comprueba que la marca de tiempo esté dentro de 5 minutos.
  8. Si coincide, responde 2xx y procesa el evento. Si no, responde 400 y no hace nada.
Ningún secreto viaja con la petición. La firma demuestra que el remitente conocía el secreto y que los bytes no cambiaron por el camino.

El esquema, con exactitud

Ikiform usa Standard Webhooks, una especificación abierta que comparten varios proveedores de webhooks. Cada entrega lleva tres cabeceras:

CabeceraValorNotas
webhook-iddlv_...El id de la entrega. Es el mismo en cada reintento, así que también sirve como clave de idempotencia
webhook-timestampSegundos Unix, como 1792300000Cuándo se firmó este intento. Cambia en cada reintento
webhook-signaturev1, más base64Uno o más valores separados por espacios. Dos mientras se rota un secreto
Qué se firma
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," + signature

Para verificar una entrega, en este orden:

  1. Lee el cuerpo sin procesar como bytes o como una cadena exacta. No lo analices todavía.
  2. Lee las tres cabeceras. Si falta alguna, rechaza la petición.
  3. 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.
  4. Quita whsec_ de tu secreto y decodifica el resto de base64. Esos 32 bytes son la clave del HMAC, no la cadena en sí.
  5. Calcula el HMAC-SHA256 de {webhook-id}.{webhook-timestamp}.{raw body} y codifícalo en base64 con el alfabeto estándar y relleno.
  6. Divide webhook-signature por los espacios. Divide cada entrada por la primera coma y salta todo lo que no tenga la versión v1.
  7. Compara cada valor v1 con el tuyo en tiempo constante. Si alguno coincide, la entrega es auténtica.
  8. 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.

EntradaValor
Secretowhsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=
webhook-iddlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp1792300000
Cuerpo sin procesarLos bytes exactos del script de abajo, sin espacios ni salto de línea
webhook-signature esperadov1,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:

Terminal
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 \
  | base64

Vale la pena un test para cada uno de estos dos casos, porque son los dos fallos reales más comunes:

CambioFirma¿Debe aceptarla tu handler?
El mismo cuerpo formateado con sangría de dos espaciosv1,se0TDhknrG0hYMvCId83HnpZfJcELG3ozxFJktRUHqY=No. Mismo JSON, bytes distintos
webhook-timestamp un segundo despuésv1,oBfWyLDPfrwVSo40e6b7B7+LmKkZX7OqT88SsrpY6ks=No. La marca de tiempo está firmada
Firmada con un segundo secreto durante una rotaciónv1,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:

Dos firmas
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=
La marca de tiempo del vector es fija, así que un verificador real la rechaza por demasiado antigua. Los tests deben pasar una hora actual fija, como hacen las guías por lenguaje. El verificador de firmas de la página de webhooks comprueba cualquier secreto, cabeceras y cuerpo en el navegador.

Formas de verificar

EnfoquePunto fuerteInconvenientes
verifyWebhook de @ikiform/sdk/webhooksUna sola llamada que lo comprueba todo y devuelve un evento tipado. Funciona en Node, Workers, Deno y BunSolo JavaScript y TypeScript
Una librería de Standard WebhooksCódigo mantenido para Python, Go, PHP, Ruby, JavaScript, Rust, Java, C# y ElixirUna 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 HMACSin dependencias, unas 20 líneas, y puedes probar cada ramaFá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 querySencilloSe 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().

app/api/webhooks/ikiform/route.ts
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;
  }
}

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:

LenguajeComparación en tiempo constante
Node.jscrypto.timingSafeEqual(a, b), que lanza un error si las longitudes difieren, así que comprueba antes la longitud
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
PHPhash_equals($known, $user)
RubyOpenSSL.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.

Mantén sincronizado el reloj del servidor. Un reloj que se desvía más de 5 minutos rechaza todas las entregas con 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:

Qué contiene webhook-signature antes, durante y después de rotar un secretoantesv1,<old>rotaciónsecretonuevo, se veuna vezsiguientes24 horasv1,<old>v1,<new>despuéssolov1,<new>desplegar el nuevoQué contiene webhook-signature antes, durante y después de rotar un secretoantesv1,<old>rotaciónsecreto nuevo, se ve una vezsiguientes 24 horasv1,<old> v1,<new>despuéssolo v1,<new>desplegar el nuevo
  1. Antes de la rotación, cada entrega lleva una firma, hecha con el secreto antiguo.
  2. Eliges Roll signing secret y copias el secreto nuevo.
  3. 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.
  4. Pasadas 24 horas, las entregas solo llevan la firma nueva, y un servidor que aún tenga el secreto antiguo las rechaza.
Tu handler no necesita código especial para esto, siempre que compruebe cada entrada v1 en lugar de solo la primera.

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 el id del 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 2xx en 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.fields contiene 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

ErrorSíntomaSolución
Usar toda la cadena whsec_... como claveno_matching_signature en todas las entregasQuita whsec_ y decodifica de base64. La clave son 32 bytes
Verificar un cuerpo ya analizadono_matching_signature, a veces solo con algunos payloadsVerifica el cuerpo sin procesar; analízalo después
Comprobar solo la primera firmaFallos que empiezan justo después de una rotaciónRecorre cada entrada v1 separada por espacios
Hex en lugar de base64no_matching_signatureStandard Webhooks usa base64 estándar con relleno, no hex ni base64url
Buscar las cabeceras distinguiendo mayúsculasmissing_headers detrás de algunos proxiesLos 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.

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.

  1. 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.
  2. En el panel, abre Webhooks, añade la URL https: pública de tu endpoint y copia el secreto whsec_ en el entorno de tu servidor.
  3. Elige Send test en la página del endpoint. El registro de entregas muestra tu 204 y el evento llega a tus logs.
  4. Cambia un carácter del secreto en tu servidor y envía otra prueba. El registro muestra 400 con no_matching_signature.
  5. Envía el formulario de verdad y comprueba que la entrega submission.created tiene éxito.

Solución de problemas

ProblemaQué hacer
400 no_matching_signatureSecreto 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_newEl 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_headersUn 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 realesLos 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 secretoEl 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

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