zylior
← Blog

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:

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
  });
}
Dos escuelas para el formato de firma: hex crudo (`a1b2c3…`) o con prefijo (`sha256=a1b2c3…`, el estilo Meta/Stripe). Elige uno, documéntalo y mantenlo configurable en el lado receptor — el día que conectes un emisor de terceros que imponga el otro, no querrás redesplegar.

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' };
}
Una regla que nos imponemos: un endpoint «firmado pero sin timestamp» es RECHAZADO, no aceptado en silencio. Si declaras una tolerancia y falta la cabecera de timestamp, devuelve 401. Si no, tienes la ilusión del anti-replay sin el anti-replay.

Las trampas que te costarán una noche

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.