HMAC-signierter Webhook: Leads sauber anbinden, ohne Bastel-Formular
Du hast ein neues SaaS, das Leads ausspuckt, und die Versuchung ist immer dieselbe: ein öffentlicher Endpoint, der jeden JSON-POST akzeptiert, um die Sicherheit kümmern wir uns später. Drei Wochen später fragst du dich, warum du 400 falsche Leads hast und einen Konkurrenten, der dein Signup-Volumen kennt. Die richtige Primitive existiert, sie passt in 30 Zeilen, und das ist der HMAC-signierte Webhook: Roh-Body, SHA-256-Signatur, Anti-Replay per Timestamp. So haben wir es bei Zylior für die eingehenden Leads all unserer SaaS verdrahtet.
Warum ein Bastel-Formular nicht hält
Ein `/api/ingest`-Endpoint, der dem Request-Body vertraut, hat drei Löcher, und sie sind alle ausnutzbar, ohne ein begabter Angreifer zu sein:
- Jeder kann posten. Die URL leakt in ein Log, einen Slack, einen Netzwerk-Tab des Browsers, und du nimmst Fake-Leads direkt in die Datenbank. Ein Secret in einem Query-Param (`?key=...`) rettet nichts: Es landet in den Access-Logs des Reverse-Proxys und im Browser-Verlauf.
- Du weißt nicht, ob der Body verändert wurde. Ohne Signatur des Inhalts kann ein falsch konfigurierter Proxy oder ein MITM eine E-Mail, einen Betrag, ein Affiliate-`ref` ändern, und du merkst nichts davon.
- Replay ist gratis. Selbst mit einem Secret: Wenn ich einmal einen gültigen Request abfange, schicke ich ihn 500-mal erneut und vermülle dein CRM — es sei denn, du signierst auch einen Timestamp und weist zu alte Requests ab.
HMAC löst alle drei auf einen Schlag: Der Sender berechnet `HMAC-SHA256(body, geteiltes_secret)` und schickt es dir in einem Header. Du berechnest es mit demselben Secret neu. Wenn es übereinstimmt, hat sich der Body nicht bewegt und der Sender kennt das Secret. Nimm den Timestamp mit in die Berechnung und du schließt dem Replay die Tür.
Sender-Seite: den Roh-Body signieren
Der Punkt, der 80 % der Integrationen zerstört: Du musst die exakten Bytes signieren, die über die Leitung gehen, nicht ein neu serialisiertes Objekt. `JSON.stringify` ist zwischen zwei Runtimes nicht deterministisch (Schlüsselreihenfolge, Leerzeichen, Unicode-Escaping). Wenn der Sender einen String signiert und der Empfänger das geparste JSON erneut stringify't, divergieren die Signaturen bei jedem zehnten Payload — bei dem mit einem Akzent oder einem Emoji. Du signierst den String einmal, du schickst ihn so, wie er ist.
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
});
}
Empfänger-Seite: in konstanter Zeit prüfen + Anti-Replay
Die Prüfung hat zwei nicht verhandelbare Fallstricke. Erstens: Hol dir den Roh-Body, BEVOR dein Framework ihn zu JSON parst: `express.json()` konsumiert den Stream, und wenn er in deinem Handler ankommt, ist der originale Body weg. Fang die Bytes im Middleware über die `verify`-Option ab und leg sie auf `req.rawBody` ab. Zweitens: niemals `berechneteSig === empfangeneSig`: Der String-Vergleich kehrt beim ersten abweichenden Byte zurück, was die erwartete Signatur per Zeitmessung leakt. Nutze `timingSafeEqual` — aber es wirft, wenn die Buffer nicht dieselbe Länge haben, also prüfe zuerst die Länge.
// 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' };
}
Die Fallstricke, die dich einen Abend kosten werden
- Zeitzone und Uhr. Der Timestamp wird in UTC-Epoch (Sekunden) signiert und verglichen, niemals in lokaler Zeit. Ein Sender auf einem falsch eingestellten Server, oder einer, der Millisekunden schickt, wo du Sekunden erwartest, und alles landet «außerhalb der Toleranz». Eine Toleranz von ±300 s schluckt die normale Uhren-Drift, ohne ein nutzbares Replay-Fenster zu öffnen.
- Body im Transit verändert. Ein Proxy, der das JSON «verschönert» (Reformatierung, BOM, Encoding-Konvertierung), ändert die Bytes und zerstört die Signatur. Das ist eine gute Nachricht: Die Signatur hat ihren Job gemacht. Logge den `reason` der Ablehnung, um das von einem falschen Secret zu unterscheiden.
- Idempotenz. Anti-Replay blockiert bösartiges Replay, aber ein legitimer Fire-and-Forget-Sender schickt denselben Lead bei einem Netzwerk-Timeout erneut. Setze einen stabilen `dedup_key` (die Business-ID, sonst ein Hash des Roh-Bodys) mit einem `ON CONFLICT DO NOTHING`.
- Validierung NACH der Signatur. Sobald das HMAC validiert ist, bleibt der Body eine öffentliche Eingabe: validiere ihn (zod). Heimtückische Falle: `z.string().url()` akzeptiert `javascript:` und `data:` — wenn ein Feld eine URL ist, erzwinge `.regex(/^https?:\/\//)`.
- Antworte schnell. Gib 200 in unter 50 ms zurück, selbst bei einem Duplikat. Fire-and-Forget-Sender wiederholen bei Langsamkeit, was dir falsches Replay erzeugt. Mach die schwere Arbeit, nachdem du den Empfang quittiert hast.
Aufsetzen, ohne reinzufallen
Generiere ein Secret pro Endpoint (32+ zufällige Bytes), verschlüssele es im Ruhezustand in der Datenbank, zeige es dem Client nur ein einziges Mal zusammen mit der URL und einem kopierfertigen Snippet. Konfiguriere das Signatur-Schema, den Header und die Toleranz per Daten — kein hartkodiertes `if (slug === 'renfort')`: Ein SaaS hinzuzufügen muss ein INSERT sein, kein Deployment. Und schreib die vier Tests, die zählen: ein gültiger signierter Payload geht durch, derselbe außerhalb des Fensters wieder eingespielt wird abgelehnt, ein verändertes Byte wird abgelehnt, eine Signatur mit der richtigen Länge, aber falsch, wird abgelehnt. Solange diese vier Fälle nicht grün sind, ist dein Webhook nicht sauber angebunden — er ist nur offen.
Der Newsletter
Mit der Anmeldung stimmst du dem Erhalt des Zylior-Newsletters zu. 1-Klick-Abmeldung in jeder E-Mail.