Zum Inhalt springen
Tutorial

Webhook-Sicherheit: HMAC-Signaturen, Idempotenz und Wiedergabeschutz

| 9 min read

Drei Codemuster stoppen gefälschte Payloads, doppelte Zustellungen und wiederholte Webhook-Anfragen. Node.js-Beispiele mit HMAC-SHA256 und Zeitstempelprüfungen.

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Ihr Webhook-Handler akzeptiert eine POST-Anfrage, analysiert den JSON und führt die Geschäftslogik aus. Das funktioniert, bis jemand sendet eine gefälschte Nutzlast an Ihren Endpunkt. Oder bis der Anbieter eine Zustellung erneut versucht und Ihr Zusteller eine Gebühr berechnet Kunde zweimal. Oder bis ein Angreifer eine legitime Anfrage aufzeichnet und sie sechs Stunden später erneut abspielt.

Drei Schutzmaßnahmen beheben dieses Problem: HMAC-Signaturüberprüfung, Idempotenzschlüssel und zeitstempelbasierte Wiedergabeablehnung. In diesem Tutorial wird jedes einzelne davon mit funktionierendem Node.js-Code behandelt.

So funktioniert die Webhook-Signierung

Jeder große Webhook-Anbieter (Stripe, GitHub, Shopify, Twilio) signiert ausgehende Payloads mit HMAC-SHA256. Die Der Anbieter kombiniert den rohen Anforderungstext mit einem gemeinsamen Geheimnis, berechnet einen Hash und sendet diesen Hash in einem Header. Ihr Server berechnet den Hash mit demselben Geheimnis neu und vergleicht ihn. Wenn die Hashes übereinstimmen, ist die Nutzlast authentisch.

Die Signaturformel sieht so aus:

HMAC-SHA256(secret, timestamp + "." + raw_body) = signature

Stripe sendet die Signatur ein Stripe-Signature. GitHub sendet es ein X-Hub-Signature-256. Der Algorithmus ist derselbe; Lediglich der Name und das Format des Headers unterscheiden sich.

Überprüfen Sie eine Stripe-Webhook-Signatur in Node.js

Streifen Stripe-Signature Header enthält einen Zeitstempel (t=) und eine oder mehrere versionierte Unterschriften (v1=). So überprüfen Sie es ohne das Stripe SDK:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

Wichtige Details: Stripe verkettet den Zeitstempel und den Rohtext mit einem Punkttrennzeichen. Der v1 Die Signatur verwendet HMAC-SHA256. Die Zeitstempelprüfung am Ende bietet einen Wiederholungsschutz (siehe unten).

Überprüfen Sie eine GitHub-Webhook-Signatur in Node.js

Das Format von GitHub ist einfacher. Der X-Hub-Signature-256 Header enthält sha256= gefolgt durch den Hex-HMAC des Rohkörpers:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

Beachten crypto.timingSafeEqual. Ein Standard === Der Vergleich lässt Timing-Informationen durchsickern. Ein Angreifer kann Antwortzeiten messen, um Byte für Byte die richtige Signatur zu erraten. Zeitkonstanter Vergleich eliminiert diesen Vektor.

Berechnen Sie HMAC-Signaturen mit der Hash-API von botoi

Wenn Sie HMAC-SHA256-Signaturen aus einem Skript, einer CI-Pipeline oder einer serverlosen Funktion berechnen oder überprüfen müssen ohne zu importieren crypto, verwenden Sie die /v1/hash/hmac Endpunkt:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

Antwort:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

Dies gibt den hexadezimal codierten HMAC zurück. Vergleichen Sie ihn mit dem Signaturheader des Webhook-Anbieters. Der Endpunkt unterstützt beides sha256 Und sha512 Algorithmen.

Erstellen Sie eine Express-Middleware für die HMAC-Verifizierung

Binden Sie die Signaturprüfung in wiederverwendbare Middleware ein, damit jede Webhook-Route geschützt wird, ohne dass Code dupliziert werden muss:

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

Diese Middleware lehnt Anfragen mit fehlenden Headern, veralteten Zeitstempeln oder ungültigen Signaturen vor Ihrer Anfrage ab Handler läuft. Das 300-Sekunden-Fenster entspricht der Standardtoleranz von Stripe.

Idempotenz: Doppellieferungen überspringen

Webhook-Anbieter wiederholen fehlgeschlagene Zustellungen. Wenn Ihr Server nach der Verarbeitung des Ereignisses, aber vorher, eine 500 zurückgegeben hat Wenn der Anbieter die 200-Antwort sendet, sendet er dasselbe Ereignis erneut. Ohne Idempotenz führt Ihr Handler die aus Nebenwirkungen doppelt: doppelte Gebühren, doppelte E-Mails, wiederholte Bestandsaktualisierungen.

Die Lösung: Speichern Sie jede verarbeitete Ereignis-ID und überspringen Sie Ereignisse, die Sie zuvor gesehen haben.

In-Memory-Idempotenz (Entwicklung)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

Der Map arbeitet für die lokale Entwicklung. Verwenden Sie in der Produktion einen gemeinsamen Speicher für alle Serverinstanzen Sehen Sie den gleichen Satz verarbeiteter Ereignisse.

Redis-gestützte Idempotenz (Produktion)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX ist atomar: Nur ein Prozess gewinnt die Sperre. Die 72-Stunden-TTL (259200 Sekunden) Bereinigt automatisch alte Einträge. Wenn die Verarbeitung fehlschlägt, löscht der Handler den Schlüssel, sodass der nächste Wiederholungsversuch erfolgreich sein kann.

Wiedergabeschutz: Veraltete Anfragen ablehnen

Die HMAC-Verifizierung bestätigt, dass die Nutzlast vom Anbieter stammt. Aber eine gültige signierte Anfrage, die von a erfasst wurde Netzwerkangreifer bleibt für immer gültig, es sei denn, Sie fügen eine Zeitprüfung hinzu. Der Wiedergabeschutz lehnt Payloads ab mit Zeitstempeln, die älter als ein Schwellenwert sind.

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

Die 30-Sekunden-Zukunftstoleranz bewältigt geringfügige Taktabweichungen zwischen Ihrem Server und dem Anbieter. Alles Älter als 5 Minuten oder mehr als 30 Sekunden in der Zukunft werden abgelehnt.

Kombinieren Sie alle drei in einer Middleware

Hier ist die komplette Middleware, die alle drei Prüfungen nacheinander ausführt: Zeitstempel, HMAC, dann Idempotenz.

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

Die Reihenfolge ist wichtig. Der Zeitstempel ist die kostengünstigste Prüfung (keine E/A) und wird daher zuerst ausgeführt. HMAC-Verifizierung ist CPU-gebunden, aber trotzdem schnell. Idempotency erfordert einen Redis-Aufruf und wird daher zuletzt ausgeführt. Diese Sequenz lehnt ab die meisten Anfragen zu den niedrigsten Kosten.

Testen Sie Ihren Webhook-Handler mit dem Webhook-Posteingang von botoi

Verwenden Sie Schaltflächen /v1/webhook/inbox Um eine Einweg-URL zu erstellen, berechnen Sie eine HMAC-Signatur mit /v1/hash/hmac, und senden Sie eine signierte Testnutzlast. Keine Tunnel, kein lokaler Server.

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

Der Listenendpunkt gibt alle empfangenen Nutzlasten zurück:

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

Mit diesem Workflow können Sie den vollständigen Signaturfluss über die Befehlszeile testen. Tauschen Sie die Posteingangs-URL gegen Ihre aus Produktionsendpunkt zum Ausführen von Integrationstests in CI.

Wie Stripe, GitHub und Shopify Webhooks signieren

Anbieterin Kopfzeile Algorithmus Signiertes Nutzlastformat Zeitstempel enthalten
Streifen Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX Ja
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX NEIN
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 Base64-kodiert NEIN
Twilio X-Twilio-Signature HMAC-SHA1 URL + sortierte Parameter NEIN

Stripe ist das einzige Unternehmen, das einen Zeitstempel mit der Signatur bündelt und Ihnen so einen sofort einsatzbereiten Wiedergabeschutz bietet. Fügen Sie für GitHub und Shopify Ihren eigenen Zeitstempel-Header hinzu oder überprüfen Sie die Ereigniserstellungszeit im Payload-Body.

Checkliste vor Produktionsbeginn

  • Speichern Sie das Webhook-Geheimnis in einer Umgebungsvariablen, nicht im Quellcode.
  • Analysieren Sie den rohen Anforderungstext, nicht die JSON-analysierte Version. HMAC signiert die genauen Bytes auf der Leitung.
  • Verwenden crypto.timingSafeEqual zum Signaturvergleich. Niemals verwenden ===.
  • Geben Sie 200 zurück, bevor Sie die langsame Verarbeitung ausführen. Stellen Sie die Arbeit in die Warteschlange und bestätigen Sie die Lieferung.
  • Protokollieren Sie abgelehnte Anfragen (ungültige Signatur, veralteter Zeitstempel, Duplikat) zur Fehlerbehebung und Warnung.
  • Legen Sie eine TTL für Ihren Idempotenzspeicher fest. 72 Stunden decken die meisten Wiederholungsfenster des Anbieters ab.
  • Testen Sie mit einem Einweg-Posteingang und berechneten HMAC-Signaturen, bevor Sie einen Live-Anbieter verbinden.

FAQ

Warum HMAC-SHA256 anstelle eines gemeinsamen Geheimnisses in einem Abfrageparameter verwenden?
Ein Abfrageparameter wird im Klartext durch Proxys und Zugriffsprotokolle übertragen. HMAC-SHA256 signiert den gesamten Anfragetext mit dem Geheimnis, sodass ein Angreifer, der die URL abfängt, trotzdem keine gültige Signatur fälschen kann.
Wie lange sollte ich verarbeitete Ereignis-IDs für Idempotenz aufbewahren?
Bewahren Sie sie mindestens 24 bis 72 Stunden lang auf. Die meisten Webhook-Anbieter versuchen es innerhalb dieses Zeitfensters erneut. Nach 72 Stunden können Sie alte Ausweise sicher aus Ihrem Geschäft entfernen.
Welche Zeitstempeltoleranz sollte ich für den Wiedergabeschutz verwenden?
Fünf Minuten (300 Sekunden) sind der Standard. Stripe benötigt 300 Sekunden. Bei kürzeren Zeitfenstern besteht die Gefahr, dass legitime Lieferungen abgelehnt werden, die durch eine Netzwerküberlastung verzögert werden.
Kann ich den Botoi-Hash/Hmac-Endpunkt verwenden, um eingehende Webhooks zu überprüfen?
Ja. POSTEN Sie den Payload-Body und Ihr Geheimnis mit dem Algorithmus sha256 nach /v1/hash/hmac. Vergleichen Sie den zurückgegebenen HMAC mit dem Signaturheader des Webhook-Anbieters.
Benötige ich alle drei Schutzmaßnahmen oder kann ich eine auswählen?
Die HMAC-Verifizierung ist das Minimum. Fügen Sie Idempotenz hinzu, wenn Ihr Webhook-Handler Nebenwirkungen wie Gebühren oder E-Mails auslöst. Fügen Sie einen Wiedergabeschutz hinzu, wenn Ihr System Finanztransaktionen oder sicherheitsrelevante Ereignisse verarbeitet.

Starte mit botoi zu entwickeln

150+ API-Endpunkte für Abfragen, Textverarbeitung, Bildgenerierung und Entwickler-Tools. Kostenloser Tarif, keine Kreditkarte nötig.