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| Componente | Descripción |
|---|---|
t | Marca de tiempo Unix en milisegundos en el momento en que se generó la firma |
v1 | Firma 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:
| Enfoque | Fiabilidad | Complejidad | Úsalo cuando |
|---|---|---|---|
| Cuerpo en bruto | Garantizada | Mayor | Producción, alta exigencia de fiabilidad |
| JSON.stringify | Muy alta | Menor | Integraciones 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.0frente a1) 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.
Next.js API Routes
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.
Next.js API Routes
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:
- Secreto correcto — Asegúrate de usar el secreto de firma de la suscripción al webhook, no un token de API
- Cabecera completa — Lee la cabecera
X-Webhook-Signaturecompleta, incluidas las partest=yv1= - 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;
}