12k
All articles

So verifizieren Sie eine Webhook-Signatur

Prüfen Sie Stripe- und GitHub-Webhook-Signaturen in Express mit rohen Request-Bodies, konstantzeitigen HMAC-Prüfungen, Replay-Schutz und Secret-Rotation.

OpenReplay Team
OpenReplay Team
So verifizieren Sie eine Webhook-Signatur

Um eine Webhook-Signatur zu verifizieren, berechnen Sie mit Ihrem gemeinsamen Secret einen HMAC-SHA256-Digest des unveränderten Request-Bodys. Diesen vergleichen Sie in konstanter Zeit mit der Signatur, die der Absender im Request-Header mitgeschickt hat. Weichen beide voneinander ab, lehnen Sie den Request ab.

Ihr Handler läuft seit Wochen in Produktion. Dann fügen Sie eine Signaturprüfung hinzu, und plötzlich schlägt jede Zustellung mit „invalid signature“ fehl, obwohl das Secret garantiert stimmt.

Ein Webhook-Endpoint ist eine öffentliche URL, an die jeder einen POST-Request senden kann. Ohne Signaturprüfung vertraut Ihr Handler allem, was dort ankommt. Wenn Sie Ihr Wissen über das Grundprinzip zunächst auffrischen möchten, finden Sie im OpenReplay-Leitfaden zu Webhooks die Grundlagen. In diesem Artikel bauen wir einen korrekten Express-Handler für Stripe und GitHub. Er behandelt die richtige Reihenfolge der Raw-Body-Middleware, den Vergleich in konstanter Zeit, den Schutz vor Replay-Angriffen und eine Prüfung mit zwei Secrets während der Rotation. Zu jedem Schritt erfahren Sie, warum er notwendig ist.

Die wichtigsten Erkenntnisse

  • Eine Webhook-Signatur ist ein HMAC-SHA256-Digest der exakten Request-Bytes. Jedes Parsen und erneute Serialisieren des JSON vor der Prüfung lässt sie daher fehlschlagen.
  • Registrieren Sie in Express die Webhook-Route mit express.raw({ type: 'application/json' }) vor jedem globalen app.use(express.json()). Der Body-Parser, der zuerst läuft, konsumiert den Request-Stream.
  • crypto.timingSafeEqual wirft einen Fehler, wenn die beiden Buffer unterschiedlich lang sind. Vergleichen Sie daher zuerst die Längen und werten Sie eine Abweichung als ungültige Signatur.
  • Stripe signiert ${t}.${rawBody} und kann pro aktivem Secret eine eigene v1-Signatur senden. GitHub signiert nur den Raw Body und liefert keinen Zeitstempel mit, daher deduplizieren Sie anhand der Delivery-ID.

So funktioniert die Verifizierung von Webhook-Signaturen

Eine Webhook-Signatur ist ein HMAC-Digest, den der Absender über den Raw Body des Requests berechnet. Dafür verwendet er ein Secret, das nur Sie und der Absender kennen. Ein übereinstimmender Digest belegt zweierlei: Die Payload wurde nicht verändert, und sie stammt von jemandem, der dieses Secret besitzt. HMAC mischt das Secret in einen SHA-256-Hash ein. Ohne das Secret kann daher niemand einen gültigen Digest für einen bestimmten Body erzeugen. Ihr Server wiederholt die Berechnung und vergleicht die Ergebnisse.

Die beiden hier behandelten Anbieter nutzen dasselbe Verfahren, signieren aber unterschiedliche Strings:

StripeGitHub
HeaderStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
Signierter String${t}.${rawBody}Raw Body
KodierungHexHex
Zeitstempel signiertJaNein
Replay-SchutzVeraltetes t ablehnenDeduplizierung über X-GitHub-Delivery

Stripe dokumentiert das Header-Format und die signierte Payload für die manuelle Verifizierung. Test-Events enthalten zusätzlich eine unechte v0-Signatur. Ignorieren Sie daher alle Schemata außer v1. GitHub beschreibt sein Verfahren unter Validating webhook deliveries: Der Wert beginnt immer mit sha256=. GitHub sendet zwar weiterhin den älteren SHA-1-Header X-Hub-Signature, behält ihn aber nur bei, damit ältere Integrationen weiter funktionieren.

Den Raw Body vor jedem JSON-Parser lesen

Der häufigste Grund für eine dauerhaft fehlschlagende Signaturprüfung ist ein JSON-Parser, der vor der Prüfung läuft. Die HMAC-Verifizierung ist byte-genau. Wenn Sie das geparste Objekt erneut serialisieren, führt jede Änderung bei Whitespace, Schlüsselreihenfolge oder Escaping dazu, dass der Digest nie übereinstimmt.

Meistens liegt der Fehler in der Reihenfolge der Middleware. Registrieren Sie in Express die Webhook-Route mit express.raw() vor jedem globalen app.use(express.json()). Sobald der JSON-Parser den Request-Stream konsumiert hat, sind die Originalbytes verloren:

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Die fehlerhafte Variante lässt sich nicht dadurch reparieren, dass Sie express.raw() an die Route anhängen. In body-parser liest der zuerst ausgeführte Parser den Stream und markiert den Request als bereits geparst. Jeder nachfolgende Parser überspringt den Request. Der Raw-Parser auf Routenebene erhält daher nichts, und req.body bleibt das Objekt, das express.json() erzeugt hat.

Ergänzen Sie den Handler um eine Prüfung mit Buffer.isBuffer(req.body). So wird aus einem irreführenden „signature mismatch“ ein klar erkennbarer Konfigurationsfehler. Der Code in diesem Artikel richtet sich an Express 5. In Express 5 ist req.body undefined, wenn der Content-Type nicht zum Parser passt, in Express 4 war es {}. Die Prüfung gibt in beiden Fällen 400 zurück.

Falls Sie die Middleware-Reihenfolge nicht ändern können, liefert Ihnen die verify-Option von express.json() den Raw-Buffer vor dem Parsen. Diesen können Sie für die spätere Verwendung auf req speichern. In einem Route Handler des Next.js App Routers läuft vor Ihrem Code kein Body-Parser. Lesen Sie die Bytes dort mit Buffer.from(await request.arrayBuffer()), verifizieren Sie sie und rufen Sie erst danach JSON.parse auf.

Erwartete Signatur berechnen und in konstanter Zeit vergleichen

Berechnen Sie den Digest mit crypto.createHmac('sha256', secret) und übergeben Sie den Buffer direkt. Wenn Sie den Body zu einem String dekodieren und wieder kodieren, entsteht ein zusätzlicher Schritt, bei dem sich Bytes verändern können. Verketten Sie für Stripe .update(`${t}.`).update(rawBody), damit der Body unverändert in den Hash einfließt.

Webhook-Signaturen müssen in konstanter Zeit verglichen werden, nicht mit ===. Ein String-Vergleich kann beim ersten abweichenden Zeichen abbrechen. Seine Laufzeit verrät daher, wie viel des geratenen Werts korrekt war. crypto.timingSafeEqual(a, b) benötigt immer gleich lang, unabhängig davon, an welcher Stelle sich die Buffer unterscheiden. Die Funktion wirft allerdings einen Fehler, wenn die beiden Buffer unterschiedlich lang sind. Prüfen Sie deshalb zuerst die Längen. So wird ein fehlerhafter Header als ungültige Signatur gewertet und bringt den Handler nicht mit einem 500er zum Absturz:

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Wenn eine Signaturprüfung immer wieder fehlschlägt, prüfen Sie Folgendes in dieser Reihenfolge:

  1. req.body ist ein Buffer (loggen Sie Buffer.isBuffer(req.body)).
  2. Das Secret gehört genau zu diesem Endpoint und Modus. Jeder Stripe-Endpoint hat ein eigenes Secret, und ein Endpoint, der sowohl im Test- als auch im Live-Modus genutzt wird, hat für jeden Modus ein separates Secret.
  3. Sendet GitHub überhaupt keinen X-Hub-Signature-256-Header, ist für den Webhook kein Secret konfiguriert. Stellen Sie sicher, dass Sie den SHA-256-Header lesen und nicht den veralteten SHA-1-Header, wie auf der GitHub-Seite zur Fehlerbehebung beschrieben.
  4. Der signierte String ist korrekt: Stripe benötigt das Präfix t., und bei GitHub muss das Präfix sha256= entfernt werden.
  5. Beide Seiten verwenden Hex. Fügen Sie den geloggten Raw Body und Ihr Secret in den HMAC-Generator von OpenReplay ein und vergleichen Sie den erzeugten Digest manuell mit dem Header.

Replays per Zeitstempel oder Delivery-ID abwehren

Eine gültige Webhook-Signatur beweist, wer einen Request gesendet hat, aber nicht, wann. Ein einmal abgefangener signierter Request kann daher später erneut eingespielt werden. Lehnen Sie bei Stripe jede Zustellung ab, deren signierter Zeitstempel älter als etwa fünf Minuten ist. Das entspricht der Standardtoleranz der Stripe-Bibliotheken. Bei GitHub, das keinen Zeitstempel signiert, speichern Sie jede Delivery-ID und überspringen Duplikate.

Prüfen Sie die Signatur vor dem Zeitstempel. Solange der HMAC über t nicht verifiziert ist, ist t lediglich Text, den ein Angreifer beliebig setzen kann. Werten Sie außerdem ein nicht-numerisches t als Fehler: Ein Vergleich mit NaN ergibt false und würde eine naive Aktualitätsprüfung stillschweigend bestehen.

Der X-GitHub-Delivery-Header enthält für jedes Event eine GUID, und bei einer erneuten Zustellung bleibt die GUID gleich. Speichern Sie die ID erst, nachdem die Verarbeitung erfolgreich war. GitHub wiederholt fehlgeschlagene Zustellungen nicht automatisch und wertet eine Zustellung als fehlgeschlagen, wenn nicht innerhalb von 10 Sekunden eine 2xx-Antwort eingeht. Wenn Sie die ID vor einem fehlgeschlagenen Durchlauf speichern, wird die später manuell ausgelöste erneute Zustellung übersprungen.

Während der Rotation zwei Secrets akzeptieren

Akzeptieren Sie während der Rotation eines Webhook-Secrets einen Request, wenn irgendein aktuell gültiges Secret einen Digest erzeugt, der mit irgendeinem v1-Wert im Header übereinstimmt. Wenn Sie das Secret eines Stripe-Endpoints rotieren, kann das alte Secret für einen von Ihnen festgelegten Zeitraum von bis zu 24 Stunden gültig bleiben. Bis es abläuft, enthält jede Zustellung eine separate v1-Signatur für jedes noch gültige Secret.

Ein Parser für den Stripe-Header, der den Header in ein Objekt überführt, behält nur ein einziges v1 und lehnt dadurch gültige Zustellungen ab. Sammeln Sie daher alle Werte in einem Array. Bei GitHub probieren Sie jedes konfigurierte Secret gegen den einzelnen Header aus. Die some()-Schleifen brechen bei einem Treffer vorzeitig ab. Das verrät jedoch nichts Verwertbares, denn jeder einzelne Vergleich erfolgt weiterhin in konstanter Zeit.

Der vollständige Express-Handler

Dieser ESM-Handler vereint alle Schritte. Er gibt bei jedem Signatur- oder Formatfehler 400 zurück (Absender unterscheiden ohnehin nur zwischen 2xx und Nicht-2xx). Für GitHub-Duplikate gibt er 200 zurück, sie werden also bestätigt und übersprungen:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Jede Prüfung fängt einen bestimmten Fehlerfall ab. Ein fehlender Header führt zu 400 statt zu einer Exception. Jeder v1-Wert bleibt erhalten. Die Längenprüfung erfolgt vor timingSafeEqual. Ein Body, der kein Buffer ist, wird als Konfigurationsfehler erkannt. Für Anwendungen, die ausschließlich Stripe nutzen, übernimmt stripe.webhooks.constructEvent aus der offiziellen Bibliothek dieselben Prüfungen. Wie Sie die Funktion aufrufen, zeigt die Stripe-Dokumentation zu Webhooks.

Fazit

Bei der Verifizierung von Webhook-Signaturen kommt es darauf an, genau die Bytes zu hashen, die angekommen sind, und das Ergebnis sicher zu vergleichen. Der Rest des Handlers sorgt dafür, dass diese Bytes unverändert bleiben, Replays abgewehrt werden und die Rotation von Secrets reibungslos funktioniert. Verschieben Sie zuerst Ihre Webhook-Routen vor express.json() und ergänzen Sie dann den oben gezeigten Handler. Falls Sie noch überlegen, ob Push-Zustellung zu Ihrer Integration passt, finden Sie unter Webhooks vs. Polling einen Vergleich der Vor- und Nachteile.

Häufig gestellte Fragen (FAQs)

Warum schlägt die Stripe-Signaturprüfung beim lokalen Testen mit der Stripe CLI fehl?

Die Stripe CLI verwendet ein eigenes Signing Secret, das sich vom Secret jedes im Stripe Dashboard angelegten Endpoints unterscheidet. Beide beginnen mit whsec_ und lassen sich daher leicht verwechseln. Wenn Sie stripe listen ausführen, gibt die CLI ihr Secret im Terminal aus. Verwenden Sie diesen Wert für Events, die die CLI weiterleitet, und verifizieren Sie weitergeleitete CLI-Events niemals mit dem Secret eines Dashboard-Endpoints oder umgekehrt.

Sollte ich constructEvent von Stripe verwenden oder die Signatur manuell verifizieren?

Verwenden Sie stripe.webhooks.constructEvent, wenn Stripe Ihr einziger Anbieter ist. Sie übergeben den Raw Body des Requests, den Wert des Stripe-Signature-Headers und Ihr Endpoint-Secret. Ist die Signatur ungültig, wirft die Funktion einen Fehler. Standardmäßig lehnen die Stripe-Bibliotheken einen signierten Zeitstempel ab, der mehr als 5 Minuten von der aktuellen Zeit abweicht. Über ein optionales Argument können Sie ein anderes Zeitfenster festlegen. Eine manuelle Verifizierung ist sinnvoll, wenn GitHub oder andere Anbieter denselben Codepfad nutzen.

Warum HMAC statt einfach Secret und Body gemeinsam mit SHA-256 zu hashen?

HMAC ist resistent gegen Length-Extension-Angriffe, die eine naive Konstruktion wie SHA-256 über das Secret gefolgt vom Body aushebeln. SHA-256 basiert auf der Merkle-Damgard-Konstruktion. Wer einen gültigen Digest besitzt, kann daher Daten an die Nachricht anhängen und ohne Kenntnis des Secrets einen gültigen Digest für die längere Nachricht berechnen. HMAC hasht den Schlüssel in zwei verschachtelten Durchläufen und verhindert so diesen Angriff. Stripe und GitHub signieren beide mit HMAC-SHA256.

Brauche ich weiterhin HTTPS, wenn ich Webhook-Signaturen verifiziere?

Ja. Eine HMAC-Signatur schützt Integrität und Authentizität, nicht aber die Vertraulichkeit. Ohne TLS kann jeder auf dem Netzwerkpfad die Payload mitlesen, die möglicherweise Kunden- oder Zahlungsdaten enthält, und einen signierten Request abfangen, um ihn später erneut einzuspielen. Signaturprüfung, Replay-Schutz und HTTPS decken jeweils eine andere Bedrohung ab. Ein produktiver Webhook-Endpoint benötigt daher alle drei.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.