Skip to content
Zurück zum Blog
Sicherheit

Webhook-Signaturprüfung fehlgeschlagen: Ursachen und Lösungen

Webhook-Signaturprüfung fehlgeschlagen? Meist liegt es am Roh-Body, der Digest-Kodierung oder einem fehlenden Zeitstempel-Präfix. Mit HMAC-Tool debuggen.

15 Min. Lesezeit

Webhook-Signaturprüfung fehlgeschlagen? Finden Sie Ihre Ursache

Ein Fehler „webhook signature verification failed“ bedeutet genau eines: Der Digest, den Ihr Code berechnet hat, ist nicht gleich dem Digest im Request-Header. Das ist die gesamte Aussage. Über Berechtigungen sagt sie nichts, über Ablaufzeiten auch nicht, und am SDK des Anbieters liegt es so gut wie nie. Irgendetwas unterscheidet sich zwischen den Bytes, die der Anbieter gehasht hat, und den Bytes, die Sie gehasht haben.

Vier Eingaben entscheiden über das Ergebnis: welche Bytes signiert wurden, welche Schlüsselbytes verwendet wurden, welcher Hash-Algorithmus lief und in welcher Textkodierung Sie verglichen haben. Ist eine davon falsch, sieht der Fehlschlag identisch aus. Die Meldung enthält keinen Hinweis darauf, welche es war, die Arbeit besteht also darin, den Raum der Eingaben einzugrenzen, statt die Meldung noch genauer zu lesen.

Wählen Sie einen Ausgangszweig:

Signatur passt nicht? Drei Zweige:
├─ Hat Ihr Framework das JSON geparst, bevor Sie es sahen?     → Abschnitt 3
├─ Trägt der Header-Wert ein Präfix oder sieht er base64 aus?  → Abschnitt 4
└─ Enthält der Header des Anbieters einen Zeitstempel?         → Abschnitt 2

Jeder Abschnitt unten endet mit etwas, das Sie gegen Ihren eigenen Payload laufen lassen können.

1. Was eine Signatur-Abweichung tatsächlich aussagt

Die Prüfung ist ein Vergleich zweier Byte-Ketten. Schlägt sie fehl, ist genau eines von vier Dingen falsch, und die vier sind voneinander unabhängig. Im deutschsprachigen Raum heißt der Vorgang je nach Team Signaturprüfung, Signaturvalidierung oder schlicht HMAC-Abgleich; die Fehlermeldung selbst kommt fast immer auf Englisch, weil sie aus dem SDK des Anbieters stammt, eine Suche nach dem englischen Wortlaut findet also mehr.

Welche Bytes signiert wurden. Der Anbieter hat eine bestimmte Folge von Bytes gehasht. Vielleicht ist das der Request-Body allein, vielleicht ein Zeitstempel, der vorn an den Body geklebt wurde. Hat Ihr Framework das JSON geparst und Ihnen ein Objekt in die Hand gegeben, besitzen Sie diese Bytes nicht mehr und können sie auch nicht zuverlässig rekonstruieren. Das ist Abschnitt 3, und es ist mit großem Abstand die häufigste Ursache.

Welche Schlüsselbytes verwendet wurden. Dieselbe Secret-Zeichenkette lässt sich als UTF-8-Text, als Hex oder als Base64 lesen, und jede Lesart ergibt einen anderen Schlüssel. Genauso ein Secret, dem der Config-Loader einen zusätzlichen Zeilenumbruch gelassen hat. In dieser Dimension steckt ein zweiter Fehlschlag: Das Secret kann schlicht das falsche Secret sein und nicht die falsche Lesart des richtigen, und darum geht es in Abschnitt 6.

In welcher Kodierung Sie verglichen haben. Ein Digest sind bei SHA-256 32 rohe Bytes. Hex und Base64 sind zwei Schreibweisen für genau diese Bytes, und sie sehen einander nie ähnlich. Vergleichen Sie das eine gegen das andere, bekommen Sie eine dauerhafte Signatur-Abweichung (hmac signature mismatch), obwohl die zugrunde liegenden Bytes übereinstimmen.

Welcher Hash-Algorithmus lief. Die meisten Anbieter nehmen SHA-256 und dokumentieren das, diese Dimension kostet Sie also in der Regel nichts. GitHub ist die Ausnahme, die man kennen sollte: Jede Zustellung trägt X-Hub-Signature (HMAC-SHA1) neben X-Hub-Signature-256 (HMAC-SHA256), und GitHubs eigene Doku schreibt, der SHA-1-Header sei „nur aus Gründen der Abwärtskompatibilität enthalten“, und empfiehlt die 256er-Variante. Greifen Sie zum falschen, verrät die Länge es, bevor die Bytes es tun. Der Body aus Abschnitt 2, mit demselben Secret unter SHA-1 signiert, ergibt sha1=ba2954d180839d8170b08b32cd38483775aaae96, also 40 Hex-Zeichen gegen die 64 seines SHA-256-Digests.

Halten Sie diese vier Dimensionen beim Debuggen getrennt. Am schnellsten isolieren Sie eine davon, indem Sie den Digest außerhalb Ihrer Anwendung aus Eingaben berechnen, die Sie kontrollieren: Fügen Sie einen Body und ein Secret in den HMAC-Generator ein und sehen Sie, was herauskommt. Sie läuft vollständig in Ihrem Browser und das Secret verlässt die Seite nicht, ein produktives Signing-Secret können Sie also gefahrlos einfügen. HMAC nutzt dieselbe SHA-256-Primitive wie ein einfacher SHA-256-Hash, nur mit Ihrem Secret als Schlüssel. Wenn Sie den Wert des Anbieters also von Hand reproduzieren können, ist die Kryptografie in Ordnung und der Bug sitzt in Ihrer Request-Verarbeitung.

2. Was die vier großen Anbieter tatsächlich signieren

Die Annahme, an der die meisten Integrationen scheitern, lautet: Jeder Anbieter signiert den Request-Body und sonst nichts. Zwei der vier Größten tun das nicht. Hier steht, was jeder von ihnen wirklich hasht, so wie die Anbieter es dokumentieren:

AnbieterHeaderSignierte ZeichenketteKodierungPräfix im WertSecretZeitstempel-Toleranz
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…Signing-Secret des Endpunkts (Präfix whsec_)5 Minuten (300 Sekunden)
GitHubX-Hub-Signature-256rawBody (kein Präfix)hexsha256=Webhook-Secret-Tokenkeine (kein Zeitstempel gesendet)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=Signing-Secret5 Minuten
ShopifyX-Shopify-Hmac-SHA256rawBodybase64keinesClient-Secret der App (kein separates Webhook-Secret)keine

Diese vier decken zufällig drei orthogonale Achsen ab. Die signierte Zeichenkette ist entweder der Body allein oder eine Verkettung mit einem Zeitstempel, und selbst das Trennzeichen unterscheidet sich: Stripe nimmt ., Slack nimmt :. Die Kodierung ist bei drei Anbietern Hex und bei einem Base64. Das Secret stammt bei drei aus einer eigenen Webhook-Credential und bei Shopify aus dem Client-Secret der App, und genau dieses Detail übersehen die meisten, weil es in der Admin-Oberfläche ein Feld mit der Aufschrift „Webhook“ gibt, das nicht das gesuchte ist.

Damit die Unterschiede greifbar werden, hier ein Body, viermal mit einem Secret signiert:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
FormWert
GitHub-Stilsha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Shopify-StilCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Stripe-Stilt=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Slack-Stilv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Lesen Sie die ersten beiden Zeilen zusammen, denn sie sind derselbe 32-Byte-Digest, zweimal aufgeschrieben. Vierundsechzig Hex-Zeichen oder vierundvierzig Base64-Zeichen samt Padding. Nichts an den beiden Zeichenketten deutet darauf hin, dass sie gleich sind, und deshalb erzeugt ein Vergleich über Kodierungsgrenzen hinweg eine Abweichung, die jede noch so gründliche Prüfung nach dem Muster „aber das Secret ist doch richtig“ übersteht.

Die letzten beiden Zeilen belegen die andere Hälfte. Gleicher Body, gleiches Secret, gleicher Algorithmus, und keiner der beiden Digests hat Ähnlichkeit mit dem von GitHub, weil die gehashte Zeichenkette jetzt mit einem Zeitstempel beginnt. Die meisten Meldungen über einen Fehler „stripe webhook signature verification failed“ laufen auf diese Zeile hinaus: Der Code hat den Body für sich gehasht und den t-Wert samt Punkt nie vorangestellt. Reproduzieren Sie alle vier im HMAC-Generator, indem Sie nur das Nachrichtenfeld bearbeiten und das Ausgabeformat umschalten, dann hört der Mechanismus auf, abstrakt zu sein.

Eine praktische Folge der Zeitstempel-Spalte: Ein Digest von Stripe oder Slack ist nur wenige Minuten gültig, Sie können also nicht heute eine Signatur mitschneiden und sie morgen in einem Test wieder abspielen. Signaturen von GitHub und Shopify bleiben dauerhaft gültig. Das erleichtert das Debuggen, heißt aber auch, dass Sie sich selbst um Replay-Schutz kümmern müssen.

3. Das Problem mit dem Roh-Body

Die meisten Meldungen über eine fehlgeschlagene Webhook-Signaturprüfung führen zu diesem Abschnitt, und die üblichen Anleitungen überspringen ihn.

Ihr Framework hat die Bytes längst zerstört

Web-Frameworks sind dazu gebaut, Ihnen das Parsen abzunehmen. Genau dieser Komfort bricht die Signaturprüfung, denn wenn Ihr Handler läuft, sind die Originalbytes weg.

express.json() liest den Request-Stream, parst ihn und ersetzt req.body durch ein JavaScript-Objekt. Der Stream ist verbraucht und lässt sich nicht erneut lesen. In FastAPI bedeutet ein deklariertes Pydantic-Modell oder ein dict-Body-Parameter, dass das Framework liest und parst, bevor Ihre Funktion überhaupt anläuft. Rails füllt params über eine Middleware aus dem JSON-Body, die vor Ihrer Controller-Action läuft. Springs Jackson-Converter verwandelt den Body in Ihre DTO-Klasse, und der darunterliegende HttpServletRequest-Input-Stream lässt sich standardmäßig nur einmal lesen.

Nichts davon ist ein Bug. Jedes einzelne dieser Teile tut, wofür es konfiguriert wurde. Das Problem ist: Eine Signatur deckt Bytes ab, ein Objekt ist keine Bytes, und das Objekt zurück in Bytes zu verwandeln ist eine andere Operation als die, die der Anbieter ausgeführt hat.

Warum Neu-Serialisieren manchmal funktioniert, und warum das die Falle ist

Der übliche Ratschlag lautet, Neu-Serialisieren verändere die Bytes. Das ist unvollständig, und die fehlende Hälfte macht diesen Fehlschlag so schwer zu diagnostizieren. Manchmal verändert es überhaupt nichts.

Hier steht JSON.stringify(JSON.parse(body)) === body, gemessen über verschiedene Payload-Formen:

Payload-FormBytes nach dem Round-TripÄnderung
{"id":42,"event":"user.created"}identischkeine, weshalb lokale Tests grün sind
{"amount":1.0}geändert{"amount":1}
{"n":1e3}geändert{"n":1000}
{"id":12345678901234567890}geändert{"id":12345678901234567000} (Präzision verloren)
{"name":"caf\u00e9"}geändert{"name":"café"} (aus 6 Bytes werden 2)
{"a":1}\ngeändertabschließender Zeilenumbruch verschluckt
{ "a" : 1 }geändertLeerraum im Inneren verschluckt
{"v":-0.0}geändert{"v":0}
{"p":0.1000000000000000055511151231257827}geändert{"p":0.1}

Sehen Sie sich die erste Zeile an. Ein flaches Objekt mit einer Ganzzahl und einer kurzen ASCII-Zeichenkette übersteht den Round-Trip Byte für Byte, ein Prüfer, der parst und neu serialisiert, besteht also jeden Test, den Sie gegen so eine Fixture geschrieben haben. Dann deployen Sie, und der erste Payload mit einem Geldbetrag von 1.0, einer ID jenseits von 2^53 oder einem Kundennamen mit Akzent schlägt fehl. Nicht alle. Nur diese.

Das ist der Mechanismus hinter „lokal grün, sporadische 401 in Produktion“, und er ist erheblich unangenehmer als ein Prüfer, der immer fehlschlägt. Ein Prüfer, der immer fehlschlägt, ist in einer Stunde repariert. Einen, der bei 3 % der Events fehlschlägt, schiebt man dem Anbieter zu, fängt ihn mit Retries ab, eskaliert ihn und erträgt ihn wochenlang. Liegt Ihre Fehlerrate strikt zwischen null und hundert Prozent, ist diese Tabelle die erste Adresse.

Die Schlüsselreihenfolge ist die Ursache, die alle erwarten, und in der Praxis die unwahrscheinlichste, denn JSON.parse erhält bei String-Schlüsseln die Einfügereihenfolge. Die echten Übeltäter sind Zahlen und Leerraum.

Den Roh-Body in jedem Framework beschaffen

In Express registrieren Sie den routenspezifischen Parser vor dem globalen JSON-Parser:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Diese Route muss VOR app.use(express.json()) registriert werden.
// body-parser markiert den Request als geparst, ein späteres raw() liefert dann stillschweigend {}.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // ein Buffer, kein Objekt
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // den Buffer direkt hashen, kein toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // alle anderen Routen bekommen weiterhin geparstes JSON
app.listen(3000);

Wenn Sie die Reihenfolge der Middleware nicht ändern können, behalten Sie stattdessen während des Parsens eine Kopie:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. Starlette cacht den Body, await request.body() liefert also die Originalbytes, selbst in einem Handler, der zusätzlich ein geparstes Modell bekommt:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # Bytes, genau wie empfangen
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, wo request.raw_post Ihnen den ungeparsten Body als Zeichenkette gibt:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go bereitet den Body nicht für Sie vor. Sie lesen ihn selbst und müssen daran denken, dass er danach leer ist:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Aus raw unmarshalen, niemals aus r.Body: dort sind keine Bytes mehr.
	w.WriteHeader(http.StatusOK)
}

In Spring umgeht die Anforderung von byte[] Jackson komplett:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper ist die Alternative, wenn ein Filter die Prüfung übernehmen muss und Sie die Controller-Signatur nicht ändern können. Es hat eine eigene Falle: getContentAsByteArray() liefert erst dann Bytes, wenn weiter unten etwas den Stream gelesen hat, ein Aufruf vor chain.doFilter(...) gibt Ihnen also ein leeres Array.

4. Kodierungs-Abweichungen: Hex, Base64 und der Schlüssel selbst

Zwischen Ihrem Digest und dem Header-Wert sitzen drei voneinander unabhängige Kodierungs-Entscheidungen, und jede kann den Vergleich für sich allein kaputt machen.

Die Digest-Kodierung. Die Ausgabe von HMAC-SHA256 sind 32 Bytes. Als Hex in Kleinbuchstaben geschrieben sind das 64 Zeichen, als Standard-Base64 sind es 44 einschließlich des =-Paddings. Die beiden Zeilen aus Abschnitt 2 zeigen genau das:

KodierungZeichenDieselben 32 Bytes, geschrieben als
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Eine schnelle Heuristik, wenn Sie auf einen unbekannten Header schauen: Sind es 64 Zeichen aus 0-9a-f, ist es Hex. Sind es 44 Zeichen mit = am Ende, oder kommen +, / oder Großbuchstaben vor, ist es Base64. Wenn Sie das bestätigen statt raten wollen, schicken Sie den Base64-Wert durch den Base64-Dekodierer und prüfen, ob 32 Bytes herauskommen; ist das der Fall, beschreiben beide Zeichenketten denselben Digest und Sie haben Textformate verglichen, keine Signaturen.

Das Präfix im Wert. GitHub schickt sha256= vor die Hex-Zeichen. Slack schickt v0=. Stripe packt alles in eine kommaseparierte Liste von key=value-Paaren. Keines dieser Zeichen ist Teil des Digests, entfernen Sie also entweder das Präfix aus dem Header oder ergänzen Sie es in Ihrem eigenen Wert. Keines von beiden zu tun ist der häufigste Einzelgrund dafür, dass eine korrekte Implementierung eine Signatur-Abweichung (hmac signature mismatch) meldet, und in Node meldet sie nicht einmal eine Abweichung, wie Abschnitt 7 erklärt.

Die Schlüsselkodierung. Das Secret sind ebenfalls Bytes, und dieselbe Zeichenkette als UTF-8, Hex oder Base64 gelesen ergibt drei verschiedene Schlüssel. Anbieter, die Ihnen ein Text-Token wie whsec_... geben, wollen UTF-8, aber viele interne Systeme verteilen Base64- oder Hex-Secrets, die Sie vor dem Signieren dekodieren müssen. Dieser Fehlermodus hat dieselbe Form wie die JWT-Variante des Problems; JWT „invalid signature“: alle Ursachen und wie Sie sie beheben geht ihn im Detail durch, samt der Frage, wie Sie erkennen, ob ein bestimmtes Secret Base64 oder Klartext ist.

5. Zeitstempel, Toleranz und Replay-Fenster

Sie können einen Digest berechnen, der perfekt passt, und trotzdem abgewiesen werden. Anbieter, die einen Zeitstempel mitschicken, erwarten, dass Sie ihn prüfen, und ein veralteter Zeitstempel ist eine gültige Signatur, die Sie dennoch ablehnen müssen.

AnbieterWo der Zeitstempel stecktFenster
Stripet= innerhalb von Stripe-Signature5 Minuten (300 Sekunden)
SlackHeader X-Slack-Request-Timestamp5 Minuten
GitHubnicht gesendetentfällt
Shopifynicht gesendetentfällt

Das Fenster falsch zu wählen tut in beide Richtungen weh. Zu großzügig, und ein mitgeschnittener Request bleibt so lange abspielbar, wie Sie es erlauben, womit der Sinn der Zeitstempelprüfung größtenteils dahin ist. Zu knapp, und gewöhnlicher Uhrenversatz beginnt echte Zustellungen abzulehnen. Fünf Minuten haben beide Anbieter gewählt, und das zu übernehmen ist ein solider Standardwert.

Bevor Sie eine Toleranz vergrößern, prüfen Sie die Uhr. Container-Images betreiben kein NTP, und eine VM, die aus einem Snapshot fortgesetzt wurde, kann Minuten hinter der Wanduhrzeit liegen, ohne dass irgendetwas in den Logs darauf hinweist. Ein Host, der stetig wegdriftet, produziert Fehlschläge, die gelegentlich beginnen und dann total werden. Das liest sich wie eine Code-Regression, ist aber keine.

Der andere Uhren-Bug ist eine Einheiten-Abweichung. Jeder Anbieter in der Tabelle schickt Epochensekunden. Vergleichen Sie das gegen einen Millisekundenwert wie JavaScripts Date.now(), ist die Differenz etwa tausendmal so groß wie das echte Alter, jedes Event liegt also außerhalb jedes plausiblen Fensters. Das Symptom ist eine Toleranzprüfung, die hundert Prozent der Zustellungen ablehnt, während der Digest selbst passt. Wenn Sie unsicher sind, welche Einheit Sie in der Hand halten, verrät es die Länge, und Epochensekunden gegenüber Millisekunden behandelt die Umrechnungen und die Zeitzonenfallen darum herum.

Verwenden Sie beim Bauen der signierten Zeichenkette den rohen Zeitstempel-String aus dem Header, keine geparste und neu formatierte Zahl. 1700000000 als Float zu parsen und wieder auszugeben kann 1700000000.0 ergeben, und das ist eine andere Byte-Folge.

6. Falsches Secret, und Secrets, die rotieren

Bevor Sie tiefer in die Kodierungen einsteigen, schließen Sie die einfachste Ursache aus: Das Secret ist möglicherweise nicht das richtige. Die Stripe-Doku sagt ausdrücklich, dass „Stripe für jeden Endpunkt einen eigenen geheimen Schlüssel erzeugt“, und dass, wenn Sie dieselbe URL sowohl auf Test- als auch auf Live-Schlüssel richten, „das Secret für jeden davon anders“ ist. Daraus folgen drei Varianten desselben Fehlers.

Testmodus und Live-Modus halten getrennte Secrets, ein Wert, den Sie kopiert haben, während das Dashboard im Testmodus stand, scheitert also an jeder Live-Zustellung. Jeder Endpunkt hat sein eigenes, und die Doku ergänzt: „Wenn Sie mehrere Endpunkte verwenden, müssen Sie für jeden, an dem Sie Signaturen prüfen wollen, ein eigenes Secret abrufen.“ Richten Sie zwei Endpunkte auf einen Handler mit einem Secret in der Umgebung, fällt die Hälfte Ihres Traffics durch. Und stripe listen gibt ein Signing-Secret für die lokale Weiterleitung des CLI aus, und das ist ein anderer Endpunkt als alles, was im Dashboard registriert ist, die beiden sind also nicht austauschbar.

Von außen sieht keiner dieser Fälle nach einem Kodierungsfehler aus. Der Digest ist wohlgeformt, der Vergleich ist korrekt, und der Wert in Ihrer Umgebung ist ein echtes Stripe-Secret, nur eben nicht das, mit dem diese Zustellung signiert wurde.

Die Rotation ist dieselbe Dimension, nur bewegt sie sich unter Ihnen. Sie sieht am wenigsten wie ein Kodierungsproblem aus und wird am häufigsten für einen Code-Bug gehalten. An Ihrem Code hat sich nichts geändert, gestern lief die Prüfung, und jetzt schlägt ein Teil der Events fehl.

Das Überlappungsfenster ist Absicht. Stripe hält das alte Endpoint-Secret nach einer Rotation bis zu 24 Stunden gültig, und in dieser Zeit trägt der Header Stripe-Signature eine v1-Signatur pro aktivem Secret. Bei Shopify läuft es umgekehrt: Nach der Rotation kann es bis zu eine Stunde dauern, bis der Anbieter die Digests mit dem neuen Secret berechnet, in der Zwischenzeit brauchen Sie also das alte.

Das Verhalten von Stripe ist das, was Code kaputt macht, weil der Header aussieht, als stünde eine einzige Signatur darin. An , splitten und das erste v1 nehmen, das man findet, funktioniert genau so lange, bis es zwei gibt, und dann treffen Sie etwa in der Hälfte der Fälle, je nachdem, welches Secret welches Event signiert hat. Iterieren Sie über alle:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // die Original-Zeichenkette behalten
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Zwei Details darin sind über die Schleife hinaus wichtig. Der Zeitstempel geht genau als die Zeichenkette in den signierten Payload, in der er angekommen ist. Und der Body kommt als Bytes dazu, nicht über String-Interpolation, die ihn zuerst als UTF-8 dekodieren würde.

Dieselbe Form gilt, wenn Sie auf Ihrer Seite rotieren: Akzeptieren Sie für die Dauer der Überlappung sowohl das alte als auch das neue Secret und lassen Sie dann das alte fallen. Worauf Sie auch rotieren, es braucht volle Entropie, erzeugen Sie es also, statt es zu tippen, etwa mit dem Signing-Secret-Generator für einen 256-Bit-Zufallswert.

7. Signaturen vergleichen, ohne Timing zu verraten

Sobald Sie zwei Digests haben, ist die Art des Vergleichs eine Sicherheitsentscheidung. Ein String-Vergleich kehrt zurück, sobald er ein abweichendes Byte findet, die benötigte Zeit verrät also, wie viele führende Bytes korrekt waren. Wer viele Requests abschicken kann, rekonstruiert damit eine gültige Signatur Byte für Byte. Über das Internet ist das langsam und laut, im lokalen Netz völlig praktikabel.

Jede Laufzeitumgebung bringt einen Vergleich in konstanter Zeit mit:

SpracheVergleich in konstanter ZeitWenn die Längen abweichen
Nodecrypto.timingSafeEqual(a, b)wirft eine Exception
Pythonhmac.compare_digest(a, b)gibt False zurück
Gohmac.Equal(a, b)gibt false zurück
PHPhash_equals($known, $user)gibt false zurück
RubyOpenSSL.secure_compare(a, b)gibt false zurück

Diese letzte Spalte ist der Ursprung einer ganzen Klasse verwirrender Vorfälle. Node ist der Ausreißer, und es scheitert nicht höflich:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Rechnen Sie durch, wann das feuert. Ein Hex-Digest von SHA-256 hat 64 Zeichen. Der Wert in X-Hub-Signature-256 hat 71, weil sha256= sieben Zeichen sind. Vergessen Sie, das Präfix zu entfernen, haben die beiden Buffer verschiedene Längen, timingSafeEqual wirft also eine Exception, statt false zurückzugeben. Ungefangen verlässt diese Exception Ihren Handler und Express macht daraus eine 500.

Überlegen Sie jetzt, was Sie sehen. Sie suchen eine Antwort mit Status 401 (webhook 401 unauthorized) und bekommen einen Serverfehler, also lesen Sie Ihren Handler, Ihren Datenbankaufruf, Ihren Event-Dispatcher. Der eigentliche Bug steht eine Zeile über dem Vergleich. Einen 64-Zeichen-Hex-Digest gegen einen 44-Zeichen-Base64-Wert zu vergleichen wirft aus demselben Grund, eine Kodierungs-Abweichung tritt in Node also ebenfalls als 500 auf statt als saubere Ablehnung.

Die Lösung ist, die Länge selbst zu prüfen und false zurückzugeben:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // Absicherung vor dem Aufruf
  return crypto.timingSafeEqual(a, b);
}

Die Länge zu verraten ist harmlos; eine Digest-Länge ist durch den Algorithmus festgelegt und öffentlich. Was Sie nicht verraten dürfen, ist, welches Präfix gepasst hat. Der Verify-Tab des HMAC-Generators verrechnet die Längendifferenz in denselben Akkumulator mit konstanter Laufzeit, statt vorzeitig zurückzukehren, eine abweichende Länge kommt also als schlichtes false zurück und nicht als Exception, und Sie können einen Header-Wert gegen Ihren berechneten Digest prüfen, ohne Wegwerf-Code zu schreiben.

8. Wenn die Transportschicht Ihre Bytes verändert hat

Sie haben die signierte Zeichenkette, den Roh-Body, die Kodierungen, die Uhr und die Rotation ausgeschlossen. Übrig bleibt die Möglichkeit, dass die Bytes, die bei Ihrem Prozess ankommen, nicht die Bytes sind, die beim Anbieter losgefahren sind.

Komprimierung. Ein Anbieter oder Proxy kann den Body gzip-komprimiert mit Content-Encoding: gzip schicken. Die Signatur deckt den unkomprimierten Payload ab, Sie müssen also nach dem Dekomprimieren hashen. Manche Frameworks dekomprimieren transparent, andere geben Ihnen die komprimierten Bytes, und ein Body, der im Log wie Binärmüll aussieht, ist das verräterische Zeichen.

Chunked Transfer. Mit Transfer-Encoding: chunked gibt es kein Content-Length, und Code, der diesem Header vertraut, um einen Lesepuffer zu dimensionieren, schneidet den Body ab. Der Digest eines abgeschnittenen Bodys ist gültiger Unsinn: Er wird nie passen, und nichts sieht falsch aus.

Proxys und WAFs. Jede Schicht, die den Body liest und umschreibt, kann ihn verändern. AWS API Gateway kann den Body base64-kodieren, bevor er eine Lambda erreicht, Sie müssen also vor dem Hashen dekodieren. Application Load Balancer, Service Meshes und Web Application Firewalls normalisieren oder kodieren Payloads ebenfalls neu. Testen Sie das, indem Sie die Bytelänge, die Ihr Handler sieht, gegen das Content-Length vergleichen, das der Anbieter geschickt hat.

Zeichenkodierung und BOM. Payloads können Nicht-ASCII-Zeichen enthalten, und die Dokumentation von GitHub ist eindeutig darin, dass der Payload als UTF-8 zu behandeln ist. Den Body im falschen Zeichensatz zu einer Zeichenkette zu dekodieren und neu zu kodieren zerstört jedes Mehrbyte-Zeichen. Eine UTF-8-Byte-Order-Mark, EF BB BF, die ein gut gemeinter Editor oder Serialisierer voranstellt, fügt drei Bytes hinzu, die nie signiert wurden.

Zeilenenden und verirrter Leerraum. Ein Body, der eine Dateigrenze im Textmodus überschritten hat, kann mit LF zu CRLF umgeschrieben ankommen. Lesen Sie auch die Spezifikation des Anbieters für die genaue Signatur-Zeichenkette: Manche hängen ein eigenes Zeichen an, und Typeform ist ein dokumentierter Fall, in dem ein abschließender Zeilenumbruch Teil des Gehashten ist. Wenn die Doku eines Anbieters ein zusätzliches Zeichen erwähnt, nehmen Sie das wörtlich.

9. Ein wiederholbarer Debugging-Ablauf

Führen Sie das in dieser Reihenfolge aus. Jeder Schritt findet entweder den Bug oder streicht einen Zweig, und früh aufzuhören ist der Sinn der Sache.

  1. Loggen Sie die Rohbytes, bevor irgendeine Middleware läuft. Schreiben Sie den Body in eine Datei, oder loggen Sie seine Bytelänge plus seinen SHA-256, vom frühesten Punkt im Request-Lebenszyklus, den Sie erreichen können. Allein die Länge klärt überraschend viele Fälle: ein Wert, der um eins größer ist als erwartet, ist ein abschließender Zeilenumbruch, drei größer ist ein BOM.
  2. Berechnen Sie den Digest von Hand. Fügen Sie genau diese Bytes und Ihr Secret in den HMAC-Generator ein, wählen Sie SHA-256 und stellen Sie das Ausgabeformat passend zum Header ein. Dieser Schritt bringt am meisten, weil er das Problem in zwei Hälften teilt.
  3. Vergleichen Sie den handberechneten Wert mit dem Header. Gleich heißt, dass Bytes und Secret beide korrekt sind und der Bug irgendwo in Ihrem Codepfad sitzt, lesen Sie also Ihren Vergleich. Nicht gleich heißt, dass eine der Eingaben falsch ist, machen Sie also weiter.
  4. Prüfen Sie die signierte Zeichenkette gegen die Tabelle aus Abschnitt 2. Stellt dieser Anbieter einen Zeitstempel voran? Mit welchem Trennzeichen? Ergänzen Sie das Präfix im Tool und rechnen Sie neu.
  5. Wechseln Sie die Digest-Kodierung. Rechnen Sie einmal als Hex und einmal als Base64 und vergleichen Sie beides gegen den Header. Ein Header-Wert mit 44 Zeichen und einem = am Ende ist Base64, ganz gleich, was Ihr Code angenommen hat.
  6. Wechseln Sie die Schlüsselkodierung. Probieren Sie das Secret als Text, dann als Hex, dann als Base64. Eine der drei ergibt üblicherweise einen Treffer, und die sagt Ihnen, was der Anbieter erwartet.
  7. Prüfen Sie Uhr und Rotationsstand. Vergleichen Sie die Zeit Ihres Servers mit einer bekannten Quelle, bestätigen Sie, dass Sie Epochensekunden verarbeiten, und sehen Sie im Dashboard des Anbieters nach einer Rotation in den letzten 24 Stunden.

Zwei Angewohnheiten machen diese Schleife deutlich schneller. Erstens: Schneiden Sie einen fehlschlagenden Payload mit und arbeiten Sie offline damit, statt auf die nächste Zustellung zu warten. Zweitens: Spielen Sie diesen mitgeschnittenen Body mit einer festen Signatur gegen Ihren Endpunkt, damit die Eingabe zwischen zwei Versuchen nie variiert. Der cURL-Befehlsgenerator baut den Request mit genau diesen Headern und einem aus einer Datei gelesenen Body zusammen, was die Bytes über alle Läufe hinweg stabil hält. Den Fehlschlag auf Abruf reproduzieren zu können verwandelt einen sporadischen Bericht über eine fehlgeschlagene Webhook-Signaturprüfung in eine Korrektur von fünf Minuten.

Wenn Sie danach immer noch ein Support-Ticket aufmachen müssen, geben Sie die Bytelänge des Bodys an, den Sie gehasht haben, den Header-Wert wörtlich, den Aufbau der signierten Zeichenkette, den Sie verwendet haben, und die Digest-Kodierung. Niemals das Secret selbst.

FAQ

Warum funktioniert meine Webhook-Signatur lokal, schlägt aber in Produktion fehl?

Ihr Test-Payload übersteht einen JSON-Round-Trip wahrscheinlich unverändert, ihn neu zu serialisieren ist also harmlos. Echte Payloads enthalten Floats, große Ganzzahlen, Unicode-Escapes oder zusätzlichen Leerraum, und die verändern die Bytes sehr wohl. Signieren Sie den Roh-Body statt einer neu serialisierten Kopie; die Tabelle in Abschnitt 3 zeigt, welche Formen brechen.

Soll ich beim Vergleich der Signaturen das Präfix sha256= einbeziehen?

Entfernen Sie es, oder ergänzen Sie es in Ihrem eigenen Wert, damit beide Zeichenketten exakt übereinstimmen. Ihr berechneter Hex-Digest hat 64 Zeichen, der Header-Wert mit Präfix 71. Manche Vergleichsfunktionen geben bei abweichender Länge false zurück, und Nodes timingSafeEqual wirft eine Exception, statt false zurückzugeben.

Kann ich die Signatur prüfen, nachdem mein Framework das JSON geparst hat?

Nicht zuverlässig. Neu-Serialisieren reproduziert die Originalbytes nur bei Payloads ohne Floats, ohne Ganzzahlen jenseits von 2^53, ohne Unicode-Escapes und ohne zusätzlichen Leerraum. Sobald eines davon auftritt, ändert sich der Digest, die Prüfung besteht also im Test und schlägt bei einem Bruchteil der Produktions-Events fehl.

Warum erzeugen Stripe und GitHub für denselben Payload unterschiedliche Signaturen?

Weil sie unterschiedliche Zeichenketten hashen. GitHub signiert den Roh-Body allein. Stripe signiert den Zeitstempel, einen wörtlichen . und dann den Body, ein Payload, der zu zwei verschiedenen Zeiten zugestellt wird, ergibt also zwei verschiedene Digests. Slack stellt v0: und seinen eigenen Zeitstempel voran. Gleicher Algorithmus, andere Eingabe.

Wie lang sollte die Zeitstempel-Toleranz sein?

Fünf Minuten nutzen Stripe und Slack, und mit diesem Wert fahren Sie gut. Kürzere Fenster lehnen legitime Zustellungen ab, sobald die Uhr Ihres Servers wegdriftet. Längere Fenster verbreitern den Zeitraum, in dem ein mitgeschnittener Request abgespielt werden kann. Synchronisieren Sie die Uhren per NTP, bevor Sie die Toleranz lockern.

Gibt timingSafeEqual false zurück, wenn die Längen abweichen?

Nein. Node wirft RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Ungefangen wird daraus eine 500 statt einer 401, was Sie dazu bringt, Ihren Handler zu debuggen statt der Zeile über dem Vergleich. Vergleichen Sie zuerst die Längen und geben Sie selbst false zurück.

Mein Anbieter hat das Secret rotiert, warum schlagen manche Webhooks trotzdem fehl?

Rotationsfenster überlappen sich. Stripe hält das alte Secret bis zu 24 Stunden gültig und schickt eine v1-Signatur pro aktivem Secret, Code, der nur das erste v1 liest, scheitert also bei etwa der Hälfte der Events. Shopify kann bis zu eine Stunde brauchen, bis es das neue Secret verwendet.

Fazit

Die Prüfung ist ein Byte-Vergleich, eine fehlgeschlagene Webhook-Signaturprüfung löst sich also immer in eine Uneinigkeit über Bytes auf und nie in etwas Kryptografisches. Halten Sie beim Debuggen die drei Dimensionen aus Abschnitt 1 getrennt und arbeiten Sie den Rest danach ab:

  • Welche Bytes signiert wurden. Schneiden Sie den Roh-Body mit, bevor ein Parser ihn anfasst. Hashen Sie nie ein neu serialisiertes Objekt, denn es passt oft genug, um Ihre Tests zu bestehen, und nicht oft genug, um zu funktionieren.
  • Welche Schlüsselbytes verwendet wurden. Ein Secret als Text, als Hex und als Base64 gelesen ergibt drei verschiedene Schlüssel.
  • In welcher Kodierung Sie verglichen haben. Hex sind 64 Zeichen, Base64 sind 44, und beide beschreiben dieselben 32 Bytes.
  • Alles andere. Das Zeitstempel-Präfix, das Präfix im Wert, das Toleranzfenster, die Überlappung bei der Rotation und die Transportschicht, ungefähr in dieser Reihenfolge der Wahrscheinlichkeit.
  • Wie Sie verglichen haben. Sichern Sie die Länge ab und nehmen Sie dann die Funktion, die Ihre Laufzeitumgebung für Vergleiche in konstanter Zeit mitbringt.

Wenn Sie einen Wert brauchen, dem Sie beim Vergleichen trauen können, berechnen Sie ihn außerhalb Ihrer Anwendung: Fügen Sie Body und Secret in den HMAC-Generator ein und lassen Sie sich sagen, welche Seite falsch liegt.

Tags: webhook hmac api-security debugging authentication

Verwandte Artikel

Alle Artikel anzeigen