Sicherheitsgrundlagen
"No signatures found matching the expected signature" bei Supabase
Ein Stripe-Webhook in einer Supabase Edge Function meldet "No signatures found matching the expected signature"? Zwei Ursachen. Prüfe weiter.

Kurz gesagt
- "No signatures found matching the expected signature for payload" heißt: Deine Funktion hat Stripes Signatur geprüft, und sie passte nicht. Entweder wurde der Body vor der Prüfung verändert, oder das Signing Secret ist nicht das, mit dem Stripe signiert hat.
- Lies den Body mit await req.text() und gib genau diesen String unverändert an constructEventAsync weiter. Vergleiche dann das whsec_-Secret deiner Funktion mit dem des Endpoints, von dem Stripe tatsächlich sendet.
- Lass den Fehler nicht verschwinden, indem du die Prüfung weglässt. Der Webhook muss mit verify_jwt = false laufen, also ist die Signatur das Einzige, was einen Fremden davon abhält, deiner App eine erfolgreiche Zahlung zu melden.
Du hast Stripe mit deiner App verbunden, eine Testzahlung ging durch, und der Webhook, der die Bestellung als bezahlt markieren soll, hat nichts getan. Stripes Dashboard zeigt eine fehlgeschlagene Zustellung, und in den Logs deiner Supabase Edge Function steht das hier:
No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Die gute Nachricht steckt darin, woher der Fehler kommt. Er wird von dem Teil deiner Funktion geworfen, der prüft, ob die Anfrage wirklich von Stripe kam. Die Prüfung existiert also und tut ihre Arbeit. "No signatures found matching the expected signature for payload" hat zwei Ursachen, und keine davon behebst du, indem du weniger prüfst. Die Lösung, die bei der Suche zuerst auftaucht, die Prüfung so einzupacken, dass der Fehler ignoriert wird, ist genau die, mit der jeder eine Bestellung als bezahlt markieren kann.
Was bedeutet "No signatures found matching the expected signature"?
Deine Funktion hat Stripes Signatur auf der Anfrage geprüft, und sie passte nicht.
Stell dir jeden Webhook von Stripe als Brief vor, der mit einem Wachssiegel
verschlossen ist. Stripe drückt das Siegel mit einem Stempel, den nur Stripe und
du besitzen: das Signing Secret deines Endpoints, ein Wert, der mit whsec_
beginnt. Kommt der Brief an, drückt deine Funktion ihre eigene Kopie des
Stempels gegen das Wachs. Passen die Muster, ist der Brief von Stripe, und
unterwegs hat ihn niemand angefasst.
Das Siegel deckt den Brief genau so ab, wie er geschrieben wurde. Stripes
Bibliothek nimmt den Text der Anfrage, die Zeit aus dem Stripe-Signature-Header
und dein Secret, berechnet, wie die Signatur aussehen müsste, und sucht sie in
diesem Header. Die Meldung oben steht
in stripe-node selbst
und wird geworfen, wenn dort nichts passt. Stripes
Seite zur Fehlersuche bei Signaturen
sagt es klar: Mindestens einer der drei Werte, die du übergibst, ist falsch.
Es gibt also zwei Stellen zum Nachsehen. Entweder wurde der Brief verändert, bevor deine Funktion das Siegel geprüft hat, oder deine Funktion hält den falschen Stempel.
Ursache 1: Der Body wurde vor der Prüfung verändert
In einer Edge Function ist die häufigste Variante ein Body, der zuerst als JSON gelesen und für die Prüfung wieder in Text verwandelt wurde.
const event = await req.json()
await stripe.webhooks.constructEventAsync(JSON.stringify(event), signature, secret, undefined, cryptoProvider)
Das liest sich natürlich und scheitert jedes Mal. req.json() behält die Daten
und wirft den Text weg: die Abstände, die Zeilenumbrüche, die Reihenfolge, in der
Stripe die Dinge geschrieben hat. JSON.stringify schreibt dann neuen Text mit
denselben Daten darin. Das Siegel wurde über Stripes Text gedrückt, und das hier
ist die Abschrift eines anderen in anderer Handschrift, also kann es nie passen.
Supabases eigenes Stripe-Webhook-Beispiel liest den Body stattdessen einmal als Text und gibt diesen String unverändert weiter:
const signature = req.headers.get('Stripe-Signature')
const body = await req.text()
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET'),
undefined,
cryptoProvider,
)
Der Kommentar in diesem Beispiel sagt, warum: Die Prüfung beruht auf dem rohen
Body der Anfrage und nicht auf dem geparsten JSON. Selbst parsen musst du danach
nichts, denn constructEventAsync gibt das Event bereits geparst zurück, sobald
das Siegel stimmt.
Dasselbe passiert außerhalb deines Codes. Stripes Seite nennt Frameworks, die einen Body parsen oder umformatieren, bevor dein Code ihn sieht, und die Meldung von stripe-node ergänzt, dass ein Tool, das dir Webhooks weiterleitet, den Body exakt erhalten muss, samt JSON-Formatierung und Art der Zeilenumbrüche. Sitzt ein Proxy oder ein Weiterleitungsdienst zwischen Stripe und deiner Funktion, prüfe, dass er den Body genau so weitergibt, wie er kam.
Ursache 2: Das Signing Secret ist das falsche
Ist der Body unverändert, ist der Stempel falsch, und Stripes Seite nennt das die häufigste Ursache überhaupt.
Drei verschiedene Werte können dort landen, wo das Signing Secret hingehört, und sie sehen sich ähnlich:
- Das Secret eines anderen Endpoints. Stripes Webhook-Anleitung sagt, dass jeder Endpoint sein eigenes Secret bekommt und dass dieselbe Adresse, im Test- und im Live-Modus eingerichtet, in jedem Modus ein anderes hat. Eine Live-Zahlung, geprüft mit dem Secret des Test-Endpoints, scheitert, und umgekehrt genauso.
- Das Secret, das
stripe listenausgibt. Wenn du Events mit Stripes Kommandozeilen-Tool auf deinen Rechner weiterleitest, gibt es ein eigeneswhsec_-Secret aus. Stripe sagt, dass es sich vom Secret des Dashboard-Endpoints unterscheidet und dass die beiden verwechselt werden. Es funktioniert für die Events, die das Tool weiterleitet, und für nichts sonst. - Dein Stripe-API-Schlüssel.
sk_live_…odersk_test_…ist der Schlüssel, mit dem deine Funktion Stripe aufruft. Er ist kein Signing Secret, und eine Prüfung damit kann nie passen.
Das richtige findest du, indem du den Endpoint in Stripes Dashboard unter
Workbench und dann Webhooks öffnest und auf Reveal secret klickst.
Prüfe vorher, in welchem Modus du bist. Vergleiche es dann mit dem, das deine
Funktion liest. Im Beispiel oben ist das STRIPE_WEBHOOK_SIGNING_SECRET,
gesetzt auf der Seite Edge Function Secrets deines Supabase-Projekts oder
mit supabase secrets set.
Wenn du schon dort bist, sieh dir noch etwas an. Wurde der Wert mit einem Leerzeichen oder einem Zeilenumbruch am Ende eingefügt, hängt stripe-node an denselben Fehler einen Hinweis an: "The provided signing secret contains whitespace." Lösche den Wert und füge ihn neu ein, ohne etwas dahinter.
Meldungen, die ähnlich aussehen und etwas anderes bedeuten
stripe-node wirft aus derselben Prüfung mehrere Fehler, und jeder zeigt auf ein anderes Problem. Alle stehen in seinem Webhook-Code:
| Was in deinen Logs steht | Was passiert ist |
|---|---|
No signatures found matching the expected signature for payload | Der Body wurde verändert, oder das Secret ist das falsche. Dieser Artikel. |
Webhook payload must be provided as a string or a Buffer | Du hast das geparste Objekt aus req.json() übergeben. Gib den Text aus req.text() weiter. |
No stripe-signature header value was provided. | Die Anfrage hatte keinen Stripe-Signature-Header. Vielleicht kam sie gar nicht von Stripe. |
Timestamp outside the tolerance zone | Das Siegel passte, aber das Event ist älter als das Limit. Stripes Bibliotheken nehmen 5 Minuten an. |
SubtleCryptoProvider cannot be used in a synchronous context. | Du hast constructEvent aufgerufen. Mit dem Web-Crypto-Provider, den Deno braucht, constructEventAsync. |
Und eine, die deinen Code nie erreicht. Zeigt Stripes Dashboard die Zustellung mit einem 401 als fehlgeschlagen und sind die Logs deiner Funktion leer, hat die Plattform von Supabase die Anfrage abgewiesen, bevor deine Funktion lief, wegen einer Prüfung, die bei einem Webhook aus sein muss.
Warum dein Webhook verify_jwt abgeschaltet braucht
Weil Stripe nicht so beweisen kann, wer es ist, wie es die Nutzer deiner App tun, und bei eingeschalteter Prüfung jede Zustellung abgewiesen wird.
Supabase stellt vor jede Edge Function eine Prüfung namens verify_jwt,
standardmäßig eingeschaltet. Sie verlangt ein Supabase-Token im
Authorization-Header der Anfrage, und Stripe hat keins, das es schicken
könnte. Supabases
Anleitung zur Funktionskonfiguration
nimmt genau das als Beispiel: Stripe-Webhooks müssen öffentlich erreichbar sein.
Sie schaltet die Prüfung für diese eine Funktion in supabase/config.toml ab:
[functions.stripe-webhook]
verify_jwt = false
Der Befehl supabase functions deploy nimmt --no-verify-jwt für dasselbe.
Supabases Stripe-Beispiel trägt dieselbe Anweisung in einem Kommentar: Stripe
prüft die Anfrage über ihre Signatur, also mit verify_jwt = false deployen.
Bei abgeschalteter Plattform-Prüfung erreicht jede Anfrage aus dem Internet deinen Code, und die Siegelprüfung in deiner Funktion ist das Einzige, was Stripes Briefe von denen aller anderen unterscheidet.
Warum du die Signaturprüfung nie überspringen solltest
Weil ohne sie jeder deiner App erzählen kann, dass eine Zahlung durchging.
Die Adresse einer Webhook-Funktion ist die URL deines Projekts, die für jeden
lesbar in deinem Frontend steht, gefolgt von /functions/v1/ und dem Namen der
Funktion, meist dem naheliegenden. Das Beispiel der Konfigurationsanleitung
nennt sie selbst stripe-webhook. Mit abgeschaltetem verify_jwt lässt die
Plattform jede Anfrage zu deinem Code durch. Prüft dein Code das Siegel nicht
mehr, glaubt er jedem Brief, der ankommt.
Also schickt ein Fremder deiner Funktion eine Anfrage mit
checkout.session.completed und einer Bestellung oder einem Kunden seiner Wahl
darin. Deine Funktion tut, was sie mit einer echten tut: markiert die
Bestellung als bezahlt, stuft das Konto hoch, gibt den Download frei. Bezahlt
hat niemand. Stripe hat keinen Eintrag dazu, weil Stripe sie nie gesendet hat.
Drei Arten, die Prüfung zu überspringen, enden alle dort:
- Sie löschen, weil sie nicht durchging.
- Den Fehler abfangen und weitermachen, also das Scheitern loggen und das Event trotzdem verarbeiten. Das Beispiel gibt in seinem catch-Block einen 400 zurück, und diese Form solltest du behalten: Eine Anfrage, die durch die Prüfung fällt, endet dort.
- Die Zeittoleranz auf 0 setzen. Stripes Webhook-Anleitung rät davon ab: 0 schaltet die Altersprüfung ganz ab, sodass ein echtes Event, das jemand abgefangen hat, später erneut gesendet werden kann.
Ein aktiver geheimer Stripe-Schlüssel in deinem Frontend ist der andere Weg, auf dem Zahlungen schiefgehen, und den sehen wir von außen: Wir fanden einen in 3 von 30.998 gescannten Apps. Unser kostenloser Scan liest das JavaScript deiner Live-App so, wie ein Fremder es lesen würde, und nennt jeden Schlüssel, den er findet, samt der Angabe, ob er dorthin gehört. Das dauert etwa 20 Sekunden und braucht kein Konto: scanne deine App.
Falls je eine gefälschte Zahlung durchkommt
Eine Prüfung, die ein Prompt entfernt hat, kündigt sich nicht an. Ein Builder, den du bittest, den Webhook wieder zum Laufen zu bringen, darf die Abkürzung oben nehmen, und deine App läuft dabei weiter.
Reeve Monitor beobachtet, was ein Fremder von außen sehen kann:
- alle neun Checks jede Stunde, für bis zu drei Apps
- eine E-Mail an dem Tag, an dem ein erneuter Scan deine App schlechter benotet als der vorige; ein geheimer Schlüssel in deinem JavaScript deckelt die Note allein schon bei D
- ob die App erreichbar ist, alle 60 Sekunden
- ein monatlicher Bericht darüber, was gesehen wurde
Ein gefälschtes Event schreibt in deine Datenbank wie ein echtes. Reeve Care hält eine Kopie deiner Supabase-Datenbank von davor.
- eine verschlüsselte Kopie nach Zeitplan, auf Reeves eigenem Speicher in der EU, außerhalb deines Supabase-Kontos
- jede Kopie wird geprüft, bevor sie zählt, durch Zählen der Zeilen in jeder Tabelle
- eine Wiederherstellung per Klick, die sichert, was jetzt da ist, bevor sie es ersetzt
- deine hochgeladenen Dateien ebenfalls, sobald du eine Storage-Zugangsberechtigung verbindest
- alles, was Monitor tut
Was jetzt zu tun ist
Was zu tun ist
- Öffne die fehlgeschlagene Zustellung in Stripes Dashboard und den Logs-Tab deiner Funktion und lies die genaue Meldung, bevor du irgendetwas änderst.
- Lies den Body mit
await req.text()und gib diesen String unverändert anconstructEventAsyncweiter. Ein Event nie mitJSON.stringifyprüfen. - Kopiere das
whsec_-Secret des Endpoints, von dem Stripe sendet, im richtigen Modus, und füge es ohne etwas dahinter in Edge Function Secrets ein. - Lass
verify_jwt = falseauf dem Webhook und die Signaturprüfung als Erstes, was die Funktion tut. - Gib einen 400 zurück, wenn die Prüfung scheitert. Den Fehler nie abfangen und das Event trotzdem verarbeiten.
Was eine Funktion, die ein Secret hält, sonst noch braucht, steht in ein Secret in eine Edge Function verschieben, und wenn du den allgemeinen Fehler von supabase-js hast, zeigt einen Non-2xx-Statuscode lesen, wo die echte Meldung steht.
FAQ
Was bedeutet "No signatures found matching the expected signature for payload"?
Deine Webhook-Funktion hat aus dem Body der Anfrage und deinem Signing Secret die Signatur berechnet, die sie erwartet, und keine der Signaturen im Stripe-Signature-Header passte dazu. Stripes Bibliothek wirft das, wenn der Body, den sie bekommt, nicht Byte für Byte dem entspricht, was Stripe gesendet hat, oder wenn das Secret nicht zu dem Endpoint gehört, der das Event geschickt hat.
Warum scheitert es, wenn ich JSON.stringify des Events übergebe?
Weil die Signatur genau den Text abdeckt, den Stripe gesendet hat, mit Leerzeichen und Zeilenumbrüchen. Liest du den Body mit req.json() und machst mit JSON.stringify wieder Text daraus, hast du dieselben Daten in anderem Text, und anderer Text hat eine andere Signatur. Lies den Body einmal mit await req.text() und gib diesen String an constructEventAsync weiter.
Welches Signing Secret nehme ich?
Das whsec_-Secret genau des Endpoints, an den Stripe liefert. Jeder Endpoint hat sein eigenes, dieselbe Adresse hat im Test- und im Live-Modus jeweils ein anderes, und der Befehl stripe listen gibt ein eigenes Secret aus, das nur für die Events gilt, die er weiterleitet. Dein sk_-API-Schlüssel ist überhaupt kein Signing Secret.
Soll meine Stripe-Webhook-Funktion verify_jwt abgeschaltet haben?
Ja. Stripe hat kein Supabase-Token, das es mitschicken könnte, also wird bei eingeschalteter Prüfung jede Zustellung mit einem 401 abgewiesen, bevor dein Code läuft. Supabases eigenes Stripe-Beispiel sagt, man solle es mit verify_jwt = false deployen. Damit ist die Signaturprüfung in deiner Funktion die einzige Prüfung überhaupt, und sie darf nie übersprungen oder abgefangen und ignoriert werden.
Kann ich die Signaturprüfung entfernen, damit Zahlungen funktionieren?
Kannst du, und dann kann jeder, der die Adresse der Funktion findet, ihr ein Event schicken, das eine erfolgreiche Zahlung meldet. Die Adresse ist deine Projekt-URL, die in deinem Frontend steht, plus der Name der Funktion. Ohne die Prüfung glaubt deine Funktion jeder Anfrage und gewährt, was laut Event bezahlt wurde.