zylior
← Blog

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:

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
  });
}
Zwei Schulen für das Signaturformat: rohes Hex (`a1b2c3…`) oder mit Präfix (`sha256=a1b2c3…`, der Meta/Stripe-Stil). Wähle eines, dokumentiere es und halte es auf der Empfänger-Seite konfigurierbar — an dem Tag, an dem du einen Drittanbieter-Sender anbindest, der das andere erzwingt, willst du nicht neu deployen.

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' };
}
Eine Regel, die wir uns auferlegen: Ein Endpoint, der «signiert, aber ohne Timestamp» ist, wird ABGELEHNT, nicht stillschweigend akzeptiert. Wenn du eine Toleranz deklarierst und der Timestamp-Header fehlt, gib 401 zurück. Sonst hast du die Illusion von Anti-Replay ohne das Anti-Replay.

Die Fallstricke, die dich einen Abend kosten werden

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.