Webhook firmado con HMAC: conecta tus leads bien, sin formulario apañado
Tienes un nuevo SaaS que escupe leads, y la tentación es siempre la misma: un endpoint público que acepta cualquier POST JSON, ya nos ocuparemos de la seguridad más adelante. Tres semanas después, te preguntas por qué tienes 400 leads falsos y un competidor que conoce tu volumen de altas. La primitiva correcta existe, cabe en 30 líneas, y es el webhook firmado con HMAC: cuerpo crudo, firma SHA-256, anti-replay por timestamp. Así es como lo cableamos en Zylior para los leads entrantes de todos nuestros SaaS.
Por qué un formulario apañado no aguanta
Un endpoint `/api/ingest` que confía en el cuerpo de la petición tiene tres agujeros, y todos son explotables sin ser un atacante con talento:
- Cualquiera puede postear. La URL se filtra en un log, un Slack, una pestaña de red del navegador, y te metes leads falsos directamente en la base de datos. Un secreto en un query param (`?key=...`) no salva nada: acaba en los logs de acceso del proxy inverso y en el historial del navegador.
- No sabes si el cuerpo fue modificado. Sin firmar el contenido, un proxy mal configurado o un MITM puede cambiar un email, un importe, un `ref` de afiliación, y tú ni te enteras.
- El replay sale gratis. Incluso con un secreto, si capturo una petición válida una vez, la reenvío 500 veces y te reviento el CRM — salvo que firmes también un timestamp y rechaces las peticiones demasiado viejas.
HMAC resuelve los tres de golpe: el emisor calcula `HMAC-SHA256(cuerpo, secreto_compartido)` y te lo envía en una cabecera. Tú lo recalculas con el mismo secreto. Si coincide, el cuerpo no se ha movido y el emisor conoce el secreto. Añade el timestamp al cálculo y le cierras la puerta al replay.
Lado emisor: firmar el cuerpo crudo
El punto que rompe el 80 % de las integraciones: debes firmar los bytes exactos enviados por la red, no un objeto reserializado. `JSON.stringify` no es determinista entre dos runtimes (orden de las claves, espacios, escape de Unicode). Si el emisor firma un string y el receptor vuelve a hacer stringify del JSON parseado, las firmas divergirán en un payload de cada diez — el que lleva un acento o un emoji. Firmas el string una vez, lo envías tal cual.
import { createHmac } from 'node:crypto';
function pushLead(payload, secret, url) {
// 1. Le corps BRUT, figé une seule fois.
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
// 2. On signe timestamp + corps (le ts entre dans le HMAC : il ne
// peut pas être bidouillé sans casser la signature).
const mac = createHmac('sha256', secret)
.update(ts + '.' + body)
.digest('hex');
return fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-zylior-signature': mac, // hex brut
'x-zylior-timestamp': ts,
},
body, // EXACTEMENT la string signée
});
}
Lado receptor: verificar en tiempo constante + anti-replay
La verificación tiene dos trampas innegociables. Primero, recupera el cuerpo crudo ANTES de que tu framework lo parsee a JSON: `express.json()` consume el stream, y cuando llega a tu handler el cuerpo original ha desaparecido. Captura los bytes en el middleware mediante la opción `verify` y guárdalos en `req.rawBody`. Segundo, nunca `firmaCalculada === firmaRecibida`: la igualdad de string sale al primer byte distinto, lo que filtra la firma esperada por medición de tiempo. Usa `timingSafeEqual` — pero lanza una excepción si los buffers no tienen la misma longitud, así que comprueba la longitud primero.
// Capter le corps brut AU PARSE, sinon il est perdu :
app.use(express.json({
limit: '2mb',
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyHmac({ secret, rawBody, signature, timestamp, toleranceSeconds }) {
if (!signature) return { ok: false, reason: 'signature manquante' };
// Anti-replay : dès qu'une fenêtre est configurée, le timestamp est
// OBLIGATOIRE (un endpoint signé sans ts = rejeu illimité).
if (toleranceSeconds > 0) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return { ok: false, reason: 'timestamp invalide' };
if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return { ok: false, reason: 'hors tolerance (replay ?)' };
}
}
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + rawBody.toString())
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature.trim());
if (a.length !== b.length) return { ok: false, reason: 'non concordante' };
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: 'non concordante' };
}
Las trampas que te costarán una noche
- Zona horaria y reloj. El timestamp se firma y se compara en epoch UTC (segundos), nunca en hora local. Un emisor en un servidor mal configurado, o que envía milisegundos donde esperas segundos, y todo pasa a «fuera de tolerancia». Una tolerancia de ±300 s absorbe el desfase de reloj normal sin abrir una ventana de replay útil.
- Cuerpo modificado en tránsito. Un proxy que «embellece» el JSON (reformateo, BOM, conversión de codificación) cambia los bytes y rompe la firma. Es una buena noticia: la firma hizo su trabajo. Loguea el `reason` del rechazo para distinguir esto de un secreto incorrecto.
- Idempotencia. El anti-replay bloquea el replay malicioso, pero un emisor legítimo en fire-and-forget reenviará el mismo lead en un timeout de red. Pon una `dedup_key` estable (el id de negocio, si no un hash del cuerpo crudo) con un `ON CONFLICT DO NOTHING`.
- Validación DESPUÉS de la firma. Una vez validado el HMAC, el cuerpo sigue siendo una entrada pública: valídalo (zod). Trampa traicionera: `z.string().url()` acepta `javascript:` y `data:` — si un campo es una URL, impón `.regex(/^https?:\/\//)`.
- Responde rápido. Devuelve 200 en menos de 50 ms, incluso en un duplicado. Los emisores fire-and-forget reintentan ante la lentitud, lo que te crea falso replay. Haz el trabajo pesado después de acusar recibo.
Montarlo sin que te la cuelen
Genera un secreto por endpoint (32+ bytes aleatorios), cífralo en reposo en la base de datos, exponlo una sola vez al cliente con la URL y un snippet listo para copiar. Configura el esquema de firma, la cabecera y la tolerancia por datos — nada de `if (slug === 'renfort')` hardcodeado: añadir un SaaS debe ser un INSERT, no un despliegue. Y escribe los cuatro tests que importan: un payload firmado válido pasa, el mismo reenviado fuera de ventana es rechazado, un byte modificado es rechazado, una firma de la longitud correcta pero falsa es rechazada. Mientras esos cuatro casos no estén en verde, tu webhook no está conectado limpiamente — solo está abierto.
La newsletter
Al suscribirte aceptas recibir la newsletter de Zylior. Baja en 1 clic en cada correo.