La mayoría de los fallos de firma de webhooks no son bugs de criptografía. Son bugs de parseo. Alguien parseó el cuerpo antes de verificar. Alguien usó hex cuando el proveedor quería base64. Alguien comparó con === cuando necesitaba una comparación timing-safe. Esta guía es la versión corta de todos los problemas de firma que hemos depurado.
El modelo que usan los proveedores
Todos los grandes proveedores de webhooks —Stripe, GitHub, Shopify, Twilio, Slack, Discord— hacen lo mismo con distintos ropajes:
- Tú y el proveedor compartís un secreto.
- El proveedor calcula una firma sobre el cuerpo de la petición (a veces más timestamp, a veces más URL).
- La firma viaja en una cabecera.
- Tu handler recalcula la firma con el mismo secreto y rechaza la petición si no coinciden.
Lo que cambia entre proveedores: el algoritmo (HMAC SHA-256 es el más común; Discord usa Ed25519), la codificación (hex vs. base64), qué se firma (cuerpo, cuerpo+timestamp, URL+cuerpo+parámetros) y qué cabecera lleva la firma.
Los cuatro bugs que explican casi todo
1. Parsear el cuerpo primero
La mayoría de los frameworks tienen un middleware JSON que parsea los cuerpos entrantes en objetos. En cuanto se ejecuta, los bytes crudos desaparecen, y cualquier firma que calcules no coincidirá: aunque vuelvas a stringificar el objeto parseado, el espaciado y el orden de las claves pueden diferir.
Solución: lee el cuerpo crudo antes de cualquier middleware en la ruta del webhook. En Express, monta express.raw({ type: 'application/json' }) solo en el path del webhook. En el App Router de Next.js, lee await request.text() directamente. En Django, usa @csrf_exempt + request.body antes de que se ejecute cualquier parser de DRF.
2. Codificación equivocada
Stripe y GitHub usan hex. Shopify usa base64. El mismo secreto produce salidas con aspecto distinto en cada uno. Ya lo cubrimos en la guía de Shopify: le pasa a todo el mundo.
3. No timing-safe
La igualdad de cadenas (===) retorna en cuanto encuentra el primer carácter distinto. La diferencia de tiempo entre "coincide el primer carácter" y "coinciden los 64 caracteres" filtra información sobre el secreto. Usa una comparación timing-safe:
// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
// Python
hmac.compare_digest(a, b)
// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)
En la práctica, el ataque es difícil de montar contra un endpoint de webhook por la internet pública, pero usar la comparación segura es tan barato que no hay razón para no hacerlo.
4. Timestamp no validado
Si la firma solo cubre el cuerpo, un atacante que capture una petición válida puede reproducirla para siempre. Los proveedores lo resuelven incluyendo un timestamp en el payload de firma y una ventana de tolerancia en la documentación (Stripe: 5 minutos; GitHub: también corta). Tu handler debe rechazar cualquier firma con un timestamp más antiguo que la tolerancia.
Salta este paso y habrás construido un sistema vulnerable a replay, por perfecto que funcione el HMAC.
Qué hay realmente en la cabecera
Las cabeceras de los proveedores llevan más que solo "la firma". Stripe envía t=1234567890,v1=abcdef...,v0=...: timestamp más varias versiones de firma. GitHub envía sha256=abcdef... con un prefijo. Twilio incluye la URL de la petición en el payload de firma, pero solo envía la firma en sí en X-Twilio-Signature. Lee la documentación del proveedor con el que integras y luego implementa contra el formato exacto.
Las pruebas locales lo hacen depurable
La verificación de firma es el peor tipo de bug: silencioso y difícil de reproducir en staging. Probar en local con un túnel y captura de peticiones te da el payload exacto que falló, la cabecera exacta que llegó y un botón de replay para reprobar el arreglo sin molestar al proveedor.
Cuando la verificación rechace algo, registra lo suficiente para depurar: la longitud del cuerpo crudo, la cabecera que leíste, la firma que calculaste. No registres el secreto. (Hemos visto a gente registrar secretos. No lo hagas.)
Un verificador mínimo correcto
// Express + Node, generic HMAC SHA-256 hex provider
app.post(
'/webhooks/foo',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-foo-signature'];
const timestamp = req.headers['x-foo-timestamp'];
// 1. Reject old timestamps (5 min tolerance)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale');
}
// 2. Compute expected signature
const expected = crypto
.createHmac('sha256', process.env.FOO_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// 3. Timing-safe compare
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)) {
return res.status(401).send('invalid');
}
// 4. Now safe to parse and process
const event = JSON.parse(req.body);
handleEvent(event);
res.status(200).end();
},
);
Cuando la verificación de firma aún falla
Si todo lo anterior está bien y aún falla, tres cosas más que comprobar: el secreto tiene espacios al final desde tu archivo de entorno, tu túnel ha reescrito una cabecera (la mayoría no, pero vale la pena confirmarlo con la captura de peticiones), o estás probando contra el secreto live mientras el dashboard muestra el de test. Nos han pasado las tres.
Para detalles específicos de cada proveedor, mira las guías de Stripe, GitHub y Shopify. O si estás mirando un 401 ahora mismo, salta a depurar errores 401 de webhook. Únete a la lista de espera de PortPreview para túnel + captura que lo hagan depurable.