Verificar firmas
La verificación HMAC-SHA256 obligatoria de los webhooks de Auvral, con código que funciona en Node, Python, Go, PHP y Ruby.
Verificar la firma es obligatorio
La URL de tu webhook es alcanzable por cualquiera que la descubra. Sin verificación, un
POST falsificado que dice verification.succeeded es indistinguible de uno real, y es el
evento que desbloquea cuentas.
Un manejador de webhooks sin verificación es un endpoint abierto para otorgarle estado de verificado a cualquiera.
Cómo se construye la firma
Cada entrega lleva:
x-auvral-signature: v1=6b2f0c...9ae4
x-auvral-timestamp: 1787761964La firma es un HMAC-SHA256, codificado en hexadecimal, sobre la cadena:
{x-auvral-timestamp}.{raw request body}con la llave del secreto de firma de tu webhook (desde la página del webhook en el dashboard).
Fíjate en los dos detalles con los que la gente tropieza: un . literal une la marca de
tiempo y el cuerpo, y el valor del header lleva el prefijo v1=, que no es parte del
hash. El prefijo es una versión del esquema, para poder agregar esquemas futuros sin
romper los manejadores existentes. Compáralo en vez de recortar los primeros tres
caracteres a ciegas.
Las tres reglas
1. Lee el cuerpo crudo antes de parsearlo.
La firma cubre bytes exactos. Si tu framework parsea el JSON antes de que tú lo veas, esos bytes se perdieron: re-serializar el objeto produce distintos espacios y otro orden de llaves, y la firma no va a coincidir. Todos los ejemplos de abajo leen los bytes crudos primero y parsean solo después de verificar.
2. Rechaza marcas de tiempo viejas.
Sin una comprobación de frescura, una petición capturada una vez se puede reproducir contra ti para siempre. Una ventana de cinco minutos es un buen valor por defecto; compara en ambas direcciones para que un reloj adelantado no acepte una marca de tiempo del futuro lejano.
3. Compara en tiempo constante.
Un == ingenuo sobre cadenas filtra información de tiempos que se puede usar para
reconstruir una firma válida byte por byte. Usa la comparación en tiempo constante de tu
lenguaje.
Y después de verificar, deduplica por x-auvral-event-id: los reintentos lo
reutilizan, y un manejador que no sea idempotente va a procesar la misma verificación
hasta seis veces.
Código que funciona
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.AUVRAL_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, signatureHeader, timestampHeader) {
if (!signatureHeader?.startsWith("v1=") || !timestampHeader) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestampHeader));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
const received = signatureHeader.slice("v1=".length);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
// express.raw, no express.json: el cuerpo ya parseado no se puede verificar
app.post("/webhooks/auvral", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("x-auvral-signature"), req.get("x-auvral-timestamp"))) {
return res.sendStatus(401);
}
const eventId = req.get("x-auvral-event-id");
if (alreadyProcessed(eventId)) return res.sendStatus(200);
markProcessed(eventId);
const event = JSON.parse(rawBody);
// confirma primero, trabaja después: el emisor expira a los 10s
res.sendStatus(200);
enqueue(event);
});If you use express.json() globally, mount express.raw() on this route specifically, or
configure a verify callback that stashes the raw buffer.
Probar tu implementación
Usa Enviar evento de prueba en el dashboard. Entrega un evento webhook.test firmado
exactamente como uno real, así que un manejador que lo acepte va a aceptar eventos reales.
Después revisa los caminos de fallo, porque esos son los que importan:
- Cambia un carácter del secreto en tu configuración. Deberías recibir
401, no200. - Reproduce una entrega con un
x-auvral-timestampviejo. Debería rechazarse. - Entrega el mismo
x-auvral-event-iddos veces. La segunda no debe hacer nada.
Cada intento, con su código de estado y el cuerpo de tu respuesta, queda registrado en la página del webhook en el dashboard.
Si la verificación falla
| Síntoma | Causa probable |
|---|---|
| La firma nunca coincide | El cuerpo se parseó y se re-serializó antes de que le aplicaras el hash |
| La firma nunca coincide | Le aplicaste el hash solo al cuerpo, sin {timestamp}. adelante |
| La firma nunca coincide | Incluiste el prefijo v1= en el valor comparado |
| Funciona con algunos eventos y falla con otros | Un proxy o un middleware está reescribiendo el cuerpo |
| El evento de prueba pasa y los reales fallan | Otro webhook, otro secreto: cada webhook tiene el suyo |