Verifica las firmas de webhook

Verifica las firmas de webhook6 min de lectura

Verifica firmas de webhook en Node.js

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.

Preet SutharFundador, Ikiform

Obtén el cuerpo de la petición como bytes sin procesar antes de que se ejecute cualquier parser de JSON y pásalo, junto con las cabeceras y tu secreto whsec_, a verifyWebhook de @ikiform/sdk/webhooks. En Express eso significa express.raw() en la ruta del webhook; en Fastify, un parser de content type de cadena limitado a esa ruta. Sin el SDK, createHmac y timingSafeEqual de node:crypto hacen lo mismo en unas 20 líneas.

Esta página trata los servidores Node de larga duración: Express y Fastify, más una versión sin dependencias que puedes probar con tests unitarios. Para route handlers en Next.js, Astro y otros frameworks, consulta la visión general; para un Cloudflare Worker con almacenamiento, consulta tu propio servidor.

Qué cambia en Node

Los frameworks de Node te ayudan con el JSON. Registra express.json() o usa los valores por defecto de Fastify y cada handler recibe req.body como objeto. Cuando se ejecuta tu código del webhook, los bytes que firmó Ikiform ya no están, y JSON.stringify(req.body) produce otros: otros espacios, otro escapado de caracteres como / o los emojis. La solución está en dónde obtienes el cuerpo, no en cómo calculas el hash.

El cuerpo de un webhook pasando por un parser global de JSON, comparado con un parser raw en la rutaexpress.json() global primeroexpress.raw() en la ruta del webhookbytes sinprocesarlo que firmóIkiformJSON.parseen elmiddlewareJSON.stringifybytesdistintos400no_matching_signaturebytes sinprocesarlo que firmóIkiformBufferintactoverifica yluegoanaliza204El cuerpo de un webhook pasando por un parser global de JSON, comparado con un parser raw en la rutaexpress.json() global primeroexpress.raw() en la ruta del webhookbytes sin procesarlo que firmó IkiformJSON.parseen el middlewareJSON.stringifybytes distintos400no_matching_signaturebytes sin procesarlo que firmó IkiformBufferintactoverifica y luego analiza204
  1. Con un parser global de JSON registrado primero, los bytes firmados se convierten en un objeto en el middleware.
  2. Al volver a serializar ese objeto sale una cadena distinta, así que la firma no puede coincidir y el handler responde 400.
  3. Con un parser raw en la ruta del webhook, el handler recibe los bytes exactos como Buffer.
  4. Verifica esos bytes, los analiza solo después y responde 204.
El orden importa. Express ejecuta los middleware en el orden en que los registras, así que la ruta del webhook va primero.

Express

server.js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json", limit: "300kb" }), async (req, res) => {
  try {
    const event = await verifyWebhook({
      payload: req.body.toString("utf8"),
      headers: req.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET,
    });
    if (event.type === "submission.created") {
      console.info("submission", event.data.submission_id, event.data.fields);
    }
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
    throw error;
  }
});

app.use(express.json());

app.listen(3000);
  • express.raw({ type: "application/json" }) solo en esta ruta te da req.body como Buffer. El express.json() global registrado después nunca ve esta petición, porque la ruta ya ha respondido.
  • El limit está por encima del tope de 256 KB del payload de Ikiform. El valor por defecto de Express, 100 KB, rechazaría un envío grande con 413.
  • verifyWebhook acepta req.headers de Node tal cual; busca las cabeceras sin distinguir mayúsculas y comprueba la marca de tiempo, cada firma v1 y el JSON.
  • En Express 5, un error lanzado desde un handler async va al middleware de errores, así que volver a lanzarlo es seguro.

Fastify

server.js
import Fastify from "fastify";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = Fastify({ logger: true });

app.register(async (webhooks) => {
  webhooks.addContentTypeParser(
    "application/json",
    { parseAs: "string", bodyLimit: 300 * 1024 },
    (_request, body, done) => done(null, body),
  );

  webhooks.post("/webhooks/ikiform", async (request, reply) => {
    try {
      const event = await verifyWebhook({
        payload: request.body,
        headers: request.headers,
        secret: process.env.IKIFORM_WEBHOOK_SECRET,
      });
      request.log.info({ type: event.type, id: event.id }, "ikiform webhook");
      return reply.code(204).send();
    } catch (error) {
      if (error instanceof WebhookVerificationError) return reply.code(400).send(error.reason);
      throw error;
    }
  });
});

await app.listen({ port: 3000, host: "0.0.0.0" });

Fastify analiza application/json por su cuenta. Registrar un parser con parseAs: "string" dentro de register lo sustituye solo para las rutas de ese plugin, gracias al encapsulamiento, así que el resto de tu API sigue recibiendo objetos analizados. request.body es entonces la cadena exacta que envió Ikiform.

Con el paquete standardwebhooks

Si prefieres usar la librería de referencia de la especificación, npm install standardwebhooks. Su clase Webhook quita el prefijo whsec_ por sí sola y acepta un cuerpo Buffer. Es síncrona y lanza WebhookVerificationError con un mensaje:

server.js
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";

const webhook = new Webhook(process.env.IKIFORM_WEBHOOK_SECRET);
const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = webhook.verify(req.body, req.headers);
    console.info(event.type, event.id);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
    throw error;
  }
});
@ikiform/sdk usa solo Web Crypto, así que necesita Node 20 o posterior. El paquete standardwebhooks incluye su propio SHA-256 y también funciona en versiones anteriores.

Con node:crypto y un test

Sin dependencias, y con un valor de retorno que dice por qué falló una petición. Calcula el hash del Buffer sin procesar directamente, así que no hay dudas de codificación de cadenas:

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export type VerifyFailure =
  | "missing_headers"
  | "invalid_timestamp"
  | "timestamp_too_old"
  | "timestamp_too_new"
  | "no_matching_signature";

type Headers = Record<string, string | string[] | undefined>;

const TOLERANCE_SECONDS = 5 * 60;

function header(headers: Headers, name: string) {
  const value = headers[name];
  return Array.isArray(value) ? value.join(" ") : value;
}

export function verifySignature(
  rawBody: Buffer | string,
  headers: Headers,
  secret: string,
  now = Date.now(),
): VerifyFailure | null {
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) return "missing_headers";
  if (!/^\d{1,12}$/.test(timestamp)) return "invalid_timestamp";

  const age = Math.floor(now / 1000) - Number(timestamp);
  if (age > TOLERANCE_SECONDS) return "timestamp_too_old";
  if (age < -TOLERANCE_SECONDS) return "timestamp_too_new";

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",", 2);
    if (version !== "v1" || !value) continue;
    const received = Buffer.from(value, "base64");
    if (received.length === expected.length && timingSafeEqual(received, expected)) return null;
  }
  return "no_matching_signature";
}
  • timingSafeEqual lanza un error cuando los dos buffers tienen longitudes distintas, así que la comprobación de longitud va primero. Una firma de longitud incorrecta no es una coincidencia.
  • Llamar dos veces a createHmac(...).update() calcula el hash del prefijo y el cuerpo como un solo flujo, igual que si los unieras.
  • Node pasa a minúsculas los nombres de las cabeceras entrantes, así que headers["webhook-id"] funciona directamente sobre req.headers.

Y un test con el vector de prueba. Ejecútalo con node --test; Node 22.18 y posteriores ejecutan archivos TypeScript directamente:

verify.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifySignature } from "./verify.ts";

const secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=";
const rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=";
const 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."}}';
const headers = {
  "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "webhook-timestamp": "1792300000",
  "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
};
const now = 1792300000 * 1000 + 30_000;

test("accepts the test vector", () => {
  assert.equal(verifySignature(Buffer.from(body), headers, secret, now), null);
});

test("accepts either secret during a roll", () => {
  const both = { ...headers, "webhook-signature": `${headers["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` };
  assert.equal(verifySignature(body, both, secret, now), null);
  assert.equal(verifySignature(body, both, rolled, now), null);
});

test("rejects a re-serialized body", () => {
  const pretty = JSON.stringify(JSON.parse(body), null, 2);
  assert.equal(verifySignature(pretty, headers, secret, now), "no_matching_signature");
});

test("rejects a stale timestamp", () => {
  assert.equal(verifySignature(body, headers, secret, now + 10 * 60_000), "timestamp_too_old");
});

El now fijo mantiene la marca de tiempo de 2026 dentro de la ventana de 5 minutos. El caso formateado es el que detecta un parser que se cuele más adelante, así que mantenlo aunque los demás parezcan obvios.

Saltar las repeticiones

Una entrega verificada aún puede llegar dos veces: si tu handler termina pero la respuesta se pierde o tarda más de 15 segundos, Ikiform reintenta con el mismo webhook-id. En un servicio Node que ya tenga Redis, un SET con la condición NX recuerda cada id. Esto usa los nombres de opción de node-redis 5 y posteriores:

deliveries.js
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

export async function firstDelivery(deliveryId) {
  const stored = await redis.set(`ikiform:delivery:${deliveryId}`, "1", {
    condition: "NX",
    expiration: { type: "EX", value: 4 * 24 * 60 * 60 },
  });
  return stored === "OK";
}

Llama a await firstDelivery(event.id) después de verificar y antes de hacer cualquier trabajo; si devuelve false, responde 204 y para. La caducidad de 4 días supera la ventana de reintentos de Ikiform, de unos tres días, así que un reintento tardío se sigue reconociendo. Sin Redis, una columna única en tu base de datos hace el mismo trabajo. Registrar el id primero significa que, si algo se cae a mitad, el reintento también se salta; si prefieres reintentar, regístralo solo cuando el trabajo salga bien.

Comprueba que funciona

  1. Ejecuta node --test. Los cuatro casos pasan.
  2. Expón el servidor en una URL https: pública, por ejemplo con cloudflared tunnel --url http://localhost:3000, y añádela en Webhooks en el panel.
  3. Pon en IKIFORM_WEBHOOK_SECRET el secreto whsec_ del endpoint y reinicia el servidor.
  4. Elige Send test. El registro de entregas muestra 204.
  5. Mueve app.use(express.json()) por encima de la ruta del webhook y envía otra prueba. Falla con 400 no_matching_signature, lo que demuestra que el test detectaría ese error. Vuelve a dejarlo como estaba.

Errores comunes

ProblemaCausa y solución
req.body es undefined o {}El type del parser raw no coincidía con el Content-Type de la petición, así que se saltó el cuerpo. Ikiform envía application/json
TypeScript dice que un Buffer no se puede asignar a stringverifyWebhook recibe el cuerpo como cadena. Pasa req.body.toString("utf8")
Funciona en local, falla en un hosting serverlessLa plataforma analizó el cuerpo por ti. En el Pages Router de Next.js, pon bodyParser: false en el config de la ruta y lee el stream tú mismo
RangeError: Input buffers must have the same byte lengthtimingSafeEqual con una firma mal formada. Comprueba las longitudes antes de comparar
413 Payload Too Large en el registro de entregasEl límite del body parser está por debajo de 256 KB. Súbelo en la ruta del webhook

Preguntas

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