zylior
← Blog

Webhook signé HMAC : brancher tes leads proprement, sans formulaire bricolé

Tu as un nouveau SaaS qui crache des leads, et la tentation est toujours la même : un endpoint public qui accepte n'importe quel POST JSON, on verra plus tard pour la sécu. Trois semaines après, tu te demandes pourquoi tu as 400 faux leads et un concurrent qui connaît ton volume de signups. La bonne primitive existe, elle tient en 30 lignes, et c'est le webhook signé HMAC : corps brut, signature SHA-256, anti-replay par timestamp. Voilà comment on l'a câblé sur Zylior pour les leads entrants de tous nos SaaS.

Pourquoi un formulaire bricolé ne tient pas

Un endpoint `/api/ingest` qui fait confiance au corps de la requête a trois trous, et ils sont tous exploitables sans être un attaquant doué :

HMAC règle les trois d'un coup : l'émetteur calcule `HMAC-SHA256(corps, secret_partagé)` et te l'envoie dans un header. Toi tu recalcules avec le même secret. Si ça concorde, le corps n'a pas bougé et l'émetteur connaît le secret. Ajoute le timestamp dans le calcul et tu fermes la porte au rejeu.

Côté émetteur : signer le corps brut

Le point qui casse 80 % des intégrations : tu dois signer les octets exacts envoyés sur le réseau, pas un objet re-sérialisé. `JSON.stringify` n'est pas déterministe entre deux runtimes (ordre des clés, espaces, échappement Unicode). Si l'émetteur signe une string et que le récepteur re-stringify le JSON parsé, les signatures divergeront sur un payload sur dix — celui avec un accent ou un emoji. Tu signes la string une fois, tu l'envoies telle quelle.

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
  });
}
Deux écoles pour le format de signature : hex brut (`a1b2c3…`) ou préfixé (`sha256=a1b2c3…`, le style Meta/Stripe). Choisis-en un, documente-le, et garde-le configurable côté récepteur — le jour où tu branches un émetteur tiers qui impose l'autre, tu ne veux pas redéployer.

Côté récepteur : vérifier en temps constant + anti-replay

La vérif a deux pièges non négociables. D'abord, récupère le corps brut AVANT que ton framework ne le parse en JSON : `express.json()` consomme le stream, et à l'arrivée dans ton handler le corps original a disparu. Capte les octets dans le middleware via l'option `verify` et range-les sur `req.rawBody`. Ensuite, jamais `sigCalculée === sigReçue` : l'égalité de string sort au premier octet différent, ce qui fuit la signature attendue par mesure de temps. Utilise `timingSafeEqual` — mais il throw si les buffers n'ont pas la même longueur, donc teste la longueur d'abord.

// 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' };
}
Règle qu'on s'impose : un endpoint « signé mais sans timestamp » est REFUSÉ, pas accepté en silence. Si tu déclares une tolérance et que le header de timestamp manque, renvoie 401. Sinon tu as l'illusion de l'anti-replay sans l'anti-replay.

Les pièges qui te coûteront une soirée

Mettre en place sans te faire avoir

Génère un secret par endpoint (32+ octets aléatoires), chiffre-le au repos en base, expose-le une seule fois au client avec l'URL et un snippet prêt à copier. Configure le schéma de signature, le header et la tolérance par données — pas de `if (slug === 'renfort')` en dur : ajouter un SaaS doit être un INSERT, pas un déploiement. Et écris les quatre tests qui comptent : un payload signé valide passe, le même rejoué hors fenêtre est refusé, un octet modifié est refusé, une signature de la bonne longueur mais fausse est refusée. Tant que ces quatre cas ne sont pas verts, ton webhook n'est pas branché proprement — il est juste ouvert.

La newsletter

En t’inscrivant, tu acceptes de recevoir la newsletter Zylior. Désinscription en 1 clic dans chaque email.