Alle Artikel
webhook securityHMACsignature verificationbest practices

Webhook-Signaturprüfung, ohne das Rätselraten

Die meisten Webhook-Signaturfehler sind keine Krypto-Bugs. Es sind Parsing-Bugs. Jemand hat den Body geparst, bevor er verifiziert hat. Jemand hat Hex genutzt, wo der Provider Base64 wollte. Jemand hat mit === verglichen, wo ein timing-safer Vergleich nötig war. Dieser Leitfaden ist die Kurzfassung jedes Signaturproblems, das wir je debuggt haben.

Das Modell, das Provider verwenden

Jeder große Webhook-Provider – Stripe, GitHub, Shopify, Twilio, Slack, Discord – macht im Kern dasselbe, nur in anderem Gewand:

  1. Du und der Provider teilt euch ein Secret.
  2. Der Provider berechnet eine Signatur über den Request-Body (manchmal plus Timestamp, manchmal plus URL).
  3. Die Signatur reist in einem Header mit.
  4. Dein Handler berechnet die Signatur mit demselben Secret neu und lehnt den Request ab, wenn sie nicht übereinstimmen.

Was sich zwischen Providern unterscheidet: der Algorithmus (HMAC SHA-256 ist am häufigsten; Discord nutzt Ed25519), die Kodierung (Hex vs. Base64), was signiert wird (Body, Body+Timestamp, URL+Body+Parameter) und welcher Header die Signatur trägt.

Die vier Bugs, die fast alles ausmachen

1. Den Body zuerst parsen

Die meisten Frameworks haben eine JSON-Middleware, die eingehende Bodies in Objekte parst. Sobald sie läuft, sind die rohen Bytes weg, und jede Signatur, die du berechnest, passt nicht mehr – selbst wenn du das geparste Objekt erneut stringifizierst, können Whitespace und Schlüsselreihenfolge abweichen.

Fix: lies den rohen Body vor jeder Middleware auf der Webhook-Route. In Express mountest du express.raw({ type: 'application/json' }) nur auf dem Webhook-Pfad. Im Next.js App Router liest du await request.text() direkt. In Django nutzt du @csrf_exempt + request.body vor jedem DRF-Parser.

2. Falsche Kodierung

Stripe und GitHub nutzen Hex. Shopify nutzt Base64. Dasselbe Secret erzeugt in jedem eine anders aussehende Ausgabe. Wir haben das im Shopify-Leitfaden behandelt – es erwischt jeden.

3. Nicht timing-safe

String-Gleichheit (===) bricht beim ersten abweichenden Zeichen früh ab. Der Zeitunterschied zwischen „das erste Zeichen stimmt“ und „alle 64 Zeichen stimmen“ verrät Informationen über das Secret. Nutze einen timing-safen Vergleich:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

In der Praxis ist der Angriff gegen einen Webhook-Endpunkt über das öffentliche Internet schwer umzusetzen, aber der sichere Vergleich ist so billig, dass es keinen Grund gibt, ihn nicht zu verwenden.

4. Timestamp nicht validiert

Wenn die Signatur nur den Body abdeckt, kann ein Angreifer, der einen gültigen Request abfängt, ihn für immer wiederholen. Provider lösen das, indem sie einen Timestamp in den Signatur-Payload aufnehmen und in den Docs ein Toleranzfenster nennen (Stripe: 5 Minuten, GitHub: ebenfalls kurz). Dein Handler sollte jede Signatur mit einem Timestamp älter als die Toleranz ablehnen.

Überspringst du diesen Schritt, hast du ein replay-anfälliges System gebaut – egal, wie perfekt der HMAC funktioniert.

Was tatsächlich im Header steht

Provider-Header tragen mehr als nur „die Signatur“. Stripe sendet t=1234567890,v1=abcdef...,v0=... – Timestamp plus mehrere Signaturversionen. GitHub sendet sha256=abcdef... mit Präfix. Twilio bezieht die Request-URL in den Signing-Payload ein, schickt aber nur die Signatur selbst in X-Twilio-Signature. Lies die Docs des Providers, gegen den du integrierst, und implementiere dann gegen das exakte Format.

Lokales Testen macht das debuggbar

Signaturprüfung ist die schlimmste Art von Bug: still und auf Staging schwer reproduzierbar. Lokales Testen mit einem Tunnel und Anfrageerfassung gibt dir den exakten Payload, der fehlschlug, den exakten Header, der ankam, und einen Replay-Button, um den Fix erneut zu testen, ohne den Provider zu belästigen.

Wenn die Prüfung etwas ablehnt, logge genug zum Debuggen: die Länge des rohen Bodys, den gelesenen Header, die berechnete Signatur. Logge nicht das Secret. (Wir haben Leute Secrets loggen sehen. Tu's nicht.)

Ein minimaler korrekter Verifier

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

Wenn die Signaturprüfung trotzdem fehlschlägt

Wenn alles oben stimmt und es trotzdem fehlschlägt, drei weitere Dinge zum Prüfen: das Secret hat abschließenden Whitespace aus deiner Env-Datei, dein Tunnel hat einen Header umgeschrieben (die meisten tun das nicht, aber mit Anfrageerfassung lohnt sich die Bestätigung), oder du testest gegen das Live-Secret, während das Dashboard das Test-Secret zeigt. Wir haben alle drei erlebt.

Für providerspezifische Stolperfallen siehe die Leitfäden zu Stripe, GitHub und Shopify. Oder wenn du gerade auf einen 401 starrst, spring zu Webhook-401-Fehler debuggen. Tritt der PortPreview-Warteliste bei für Tunnel + Erfassung, die das debuggbar machen.

Häufig gestellte Fragen

Warum schlägt meine Webhook-Signaturprüfung fehl?
Meistens wurde der Body von Middleware geparst, bevor die Prüfung lief. Andere häufige Ursachen: Hex- vs. Base64-Kodierungs-Mismatch, ein nicht timing-safer Vergleich, der in bestimmten Fällen scheitert, oder das falsche Secret (Test vs. Live, Dashboard vs. CLI).
Brauche ich für Webhook-Signaturen wirklich einen timing-safen Vergleich?
Ja. Einfache String-Gleichheit verrät Byte für Byte Timing-Informationen über das Secret. Der Angriff ist in der Praxis schwer umzusetzen, aber der sichere Vergleich ist eine Codezeile – es gibt keinen Grund, ihn nicht zu nutzen.
Warum enthält meine Webhook-Signatur einen Timestamp?
Ohne Timestamp kann ein Angreifer, der einen gültigen signierten Request abfängt, ihn für immer wiederholen. Der Timestamp lässt deinen Handler alles ablehnen, was älter als das Toleranzfenster des Providers ist, typischerweise wenige Minuten.