Skip to Content
WebhooksVerificación de firma

Verificación de firma

Verifica siempre las firmas de los webhooks antes de procesar el cuerpo. Así te aseguras de que la solicitud es realmente de Ignite y no ha sido manipulada.

¿Por qué verificar las firmas?

Los endpoints de webhook son URLs públicas. Sin verificación de firma, cualquiera podría enviar eventos falsos a tu endpoint. La verificación de firma garantiza:

  • Autenticidad — La solicitud procede de Ignite
  • Integridad — El cuerpo no ha sido modificado en tránsito

Formato de la firma

Cada solicitud de webhook incluye una cabecera X-Webhook-Signature con el siguiente formato:

t=1705316400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
ComponenteDescripción
tMarca de tiempo Unix en milisegundos en el momento en que se generó la firma
v1Firma HMAC-SHA256 (codificada en hexadecimal)

Algoritmo de verificación

La firma se calcula sobre una combinación de la marca de tiempo y el cuerpo de la solicitud. Hay dos enfoques:

Opción A: Cuerpo en bruto (recomendado)

signed_payload = timestamp + "." + raw_body signature = HMAC-SHA256(signed_payload, signing_secret)

Opción B: JSON.stringify

signed_payload = timestamp + "." + JSON.stringify(body) signature = HMAC-SHA256(signed_payload, signing_secret)

Donde raw_body son los bytes exactos recibidos en la solicitud, y JSON.stringify(body) vuelve a serializar el objeto JSON parseado.

Enfoques de verificación

Hay dos formas de verificar la firma. Elige la que encaje con tu caso:

EnfoqueFiabilidadComplejidadÚsalo cuando
Cuerpo en brutoGarantizadaMayorProducción, alta exigencia de fiabilidad
JSON.stringifyMuy altaMenorIntegraciones rápidas, cuando acceder al cuerpo en bruto es difícil

¿Por qué la diferencia?

La firma se calcula sobre los bytes exactos que envía Ignite. Si usas JSON.stringify() sobre el cuerpo ya parseado:

  • Suele funcionar: JSON.stringify() es determinista para el mismo objeto en JavaScript
  • Posibles problemas: El orden de las claves en objetos anidados, el formato numérico (1.0 frente a 1) o el escape Unicode podrían diferir teóricamente entre serializadores

En la práctica, JSON.stringify() funciona de forma fiable porque tanto Ignite como tu código usan serialización JSON estándar. Aun así, usar el cuerpo en bruto da una garantía del 100 %.


Opción A: Usar el cuerpo en bruto (recomendado)

Este enfoque usa los bytes exactos recibidos, de modo que la firma coincide siempre.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Disable the default body parser to access raw body export const config = { api: { bodyParser: false, }, }; async function getRawBody(req: NextApiRequest): Promise<string> { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks).toString('utf-8'); } function isValidSignature(rawBody: string, signatureHeader: string | string[] | undefined, secret: string): boolean { try { if (typeof signatureHeader !== "string") { return false; } const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${rawBody}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } const rawBody = await getRawBody(req); if (!isValidSignature(rawBody, req.headers["x-webhook-signature"], process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = JSON.parse(rawBody); // Process the webhook... return res.status(200).json({ received: true }); }

Opción B: Usar JSON.stringify (más sencillo)

Este enfoque vuelve a serializar el cuerpo parseado. Es más simple pero depende de que la serialización JSON sea coherente entre Ignite y tu código.

En la práctica este enfoque funciona de forma fiable porque ambas partes usan JSON.stringify() estándar sin opciones de formato. Úsalo cuando acceder al cuerpo en bruto sea incómodo o necesites una integración rápida.

import { createHmac, timingSafeEqual } from "crypto"; import { NextApiRequest, NextApiResponse } from "next"; // Using the default body parser (no config export needed) function isValidSignature(req: NextApiRequest, secret: string): boolean { try { const signature = req.headers["x-webhook-signature"]; if (typeof signature !== "string") { return false; } const time = signature.match(/t=(\d+)/)?.[1]; const token = signature.match(/v1=(\w+)/)?.[1]; if (!time || !token) { return false; } const signedPayload = `${time}.${JSON.stringify(req.body)}`; const hmac = createHmac("sha256", secret); const calculatedSignature = hmac.update(signedPayload).digest("hex"); return timingSafeEqual(Buffer.from(token), Buffer.from(calculatedSignature)); } catch { return false; } } export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { return res.status(405).end(); } if (!isValidSignature(req, process.env.WEBHOOK_SIGNING_SECRET!)) { return res.status(403).end(); } const body = req.body; // Process the webhook... return res.status(200).json({ received: true }); }

Validación de marca de tiempo (opcional)

Para evitar ataques de repetición, también puedes comprobar que la marca de tiempo sea reciente:

function isTimestampValid(signatureHeader: string, toleranceMs = 300000): boolean { const time = signatureHeader.match(/t=(\d+)/)?.[1]; if (!time) return false; const timestamp = parseInt(time, 10); const now = Date.now(); // Reject if timestamp is more than 5 minutes old (default) return Math.abs(now - timestamp) <= toleranceMs; }

Resolución de problemas

La firma no coincide

Si falla la verificación de firma, revisa lo siguiente:

  1. Secreto correcto — Asegúrate de usar el secreto de firma de la suscripción al webhook, no un token de API
  2. Cabecera completa — Lee la cabecera X-Webhook-Signature completa, incluidas las partes t= y v1=
  3. Integridad del cuerpo — Si usas la opción B, ningún middleware debe modificar el cuerpo antes de verificar

Depuración

Añade logs para diagnosticar problemas de firma:

function isValidSignature(rawBody, signatureHeader, secret) { const time = signatureHeader.match(/t=(\d+)/)?.[1]; const token = signatureHeader.match(/v1=(\w+)/)?.[1]; const signedPayload = `${time}.${rawBody}`; const calculatedSignature = crypto.createHmac("sha256", secret) .update(signedPayload) .digest("hex"); console.log('Received signature:', token); console.log('Calculated signature:', calculatedSignature); console.log('Payload length:', rawBody.length); console.log('First 100 chars:', rawBody.substring(0, 100)); return token === calculatedSignature; }