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é :
- N'importe qui peut poster. L'URL fuite dans un log, un Slack, un onglet réseau du navigateur, et tu prends des leads bidon directement en base. Un secret dans un query param (`?key=...`) ne sauve rien : il finit dans les logs d'accès du reverse-proxy et l'historique du navigateur.
- Tu ne sais pas si le corps a été modifié. Sans signature du contenu, un proxy mal configuré ou un MITM peut changer un email, un montant, un `ref` d'affiliation, et tu n'en sais rien.
- Le rejeu est gratuit. Même avec un secret, si je capture une requête valide une fois, je la renvoie 500 fois et je pourris ton CRM — sauf si tu signes aussi un timestamp et refuses les requêtes trop vieilles.
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
});
}
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' };
}
Les pièges qui te coûteront une soirée
- Fuseau horaire et horloge. Le timestamp se signe et se compare en epoch UTC (secondes), jamais en heure locale. Un émetteur sur un serveur mal réglé, ou qui envoie des millisecondes là où tu attends des secondes, et tout passe « hors tolérance ». Une tolérance de ±300 s absorbe le décalage d'horloge normal sans ouvrir une fenêtre de rejeu utile.
- Corps modifié en transit. Un proxy qui « embellit » le JSON (reformatage, BOM, conversion d'encodage) change les octets et casse la signature. C'est une bonne nouvelle : la signature a fait son boulot. Logue le `reason` du refus pour distinguer ça d'un mauvais secret.
- Idempotence. L'anti-replay bloque le rejeu malveillant, mais un émetteur légitime en fire-and-forget renverra le même lead sur un timeout réseau. Pose une `dedup_key` stable (l'id métier, sinon un hash du corps brut) avec un `ON CONFLICT DO NOTHING`.
- Validation APRÈS la signature. Une fois le HMAC validé, le corps reste une entrée publique : valide-le (zod). Piège vicieux : `z.string().url()` accepte `javascript:` et `data:` — si un champ est une URL, impose `.regex(/^https?:\/\//)`.
- Réponds vite. Renvoie 200 en moins de 50 ms, même sur un doublon. Les émetteurs fire-and-forget retentent sur lenteur, ce qui te crée du faux rejeu. Fais le travail lourd après avoir accusé réception.
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.