Stripe-Webhook-Signaturprüfung fehlgeschlagen — finde heraus, warum
Stripes constructEvent wirft No signatures found matching the expected signature for payload, und du hast keine Ahnung, welche von einem Dutzend Möglichkeiten schiefgegangen ist. Diese Seite macht genau eine Sache: Sie nimmt die drei Artefakte, die Stripe dir gibt — das whsec_-Secret, den exakten Rohbody und den Stripe-Signature-Header —, berechnet den HMAC neu und zeigt auf die eine Ursache, die es wirklich kaputt gemacht hat.
Kurz gesagt: In 9 von 10 Fällen ist es der Rohbody (dein Framework hat das JSON vor der Prüfung neu serialisiert) oder das falsche Signing-Secret (das whsec_ der CLI statt das des Endpoints). Der Debugger unten unterscheidet die beiden mit einem einzigen Einfügen.
Deine Stripe-Signatur debuggen
Stripe ist standardmäßig ausgewählt. Der HMAC wird in deinem Browser mit WebCrypto berechnet — dein whsec_ verlässt den Tab nie.
Anatomie des Stripe-Signature-Headers
Jeder Stripe-Webhook kommt mit einem Header an, der so aussieht. Jedes kommagetrennte Feld hat eine Aufgabe:
Stripe-Signature: t=1614556800, v1=5257a869e7ecebeda32affa62cdca3fa51cad7414a563a8c6e3c4b41c2b7f2c8, v0=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
| Anatomie des Stripe-Signature-Headers | |
|---|---|
| t= | Unix-Timestamp (Sekunden), zu dem Stripe die Signatur erzeugt hat. Er ist Teil dessen, was signiert wird — und das, womit die 5-Minuten-Replay-Prüfung vergleicht. |
| v1= | Die Signatur, die du tatsächlich prüfst: HMAC-SHA256 des Strings t + "." + rawBody, hex-kodiert, mit dem whsec_ deines Endpoints als Schlüssel. Das ist das einzige Schema, das constructEvent prüft. |
| v0= | Ein veraltetes Test-Schema. Stripe sendet es noch, aber die Library ignoriert es bei der Prüfung — versuch nicht, gegen v0 zu matchen. |
| , (Komma) | Während einer Secret-Rotation signiert Stripe mit dem alten und dem neuen Secret, du kannst also zwei v1=-Werte sehen. constructEvent ist erfolgreich, sobald einer davon passt. |
Der signierte String ist `${t}.${rawBody}` — nicht der Body allein. Vergisst du in einem selbstgebauten Verifier das Präfix t., scheitert jede Signatur, selbst mit dem richtigen Secret.
Die 6 Grundursachen, in der Reihenfolge, in der du sie prüfen solltest
Sortiert danach, wie oft sie der Übeltäter sind. Jede hat ein Merkmal, das sie von den anderen unterscheidet.
| # | Ursache | Typisches Symptom | Lösung |
|---|---|---|---|
| 1 | Body ist nicht die Rohbytes | Scheitert bei echten Events, aber der berechnete HMAC ist nah dran; klappt, wenn du den exakten Roh-String in den Debugger oben einfügst. | Gib constructEvent den unveränderten Request-Body, bevor irgendein JSON-Parser ihn anfasst. |
| 2 | Falsches Signing-Secret | Jedes Event scheitert, der HMAC ist weit vom v1 entfernt. Oft das whsec_ der CLI, verwendet für Dashboard-Traffic. | Kopiere das Secret von der Seite des Endpoints im Dashboard (Developers → Webhooks), nicht aus stripe listen. |
| 3 | Middleware hat den Body zuerst geparst | Klappt mit stripe trigger, liefert aber in Produktion 400; Express app.use(express.json()) läuft vor deiner Route. | Hänge express.raw({type:'application/json'}) nur an die Webhook-Route, oberhalb des globalen JSON-Parsers. |
| 4 | Test-Secret im Live-Modus (oder umgekehrt) | Die Signatur scheitert nur bei echten Zahlungen, Test-Events klappen. Das whsec_ sieht richtig aus, gehört aber zum anderen Modus. | Nutze für Live-Traffic das whsec_ des Live-Endpoints; Test und Live haben jeweils ein eigenes. |
| 5 | Header fehlt oder ist abgeschnitten | sig ist undefined oder leer; ein Proxy oder CDN hat Stripe-Signature entfernt oder kleingeschrieben. | Lies req.headers['stripe-signature'] (in Node kleingeschrieben) und stell sicher, dass dein Proxy den Header weiterleitet. |
| 6 | Secret des falschen Endpoints | Ein Endpoint verifiziert, ein anderer liefert 400 — du hast ein einziges whsec_ für zwei Endpoint-URLs wiederverwendet. | Jeder Endpoint hat sein eigenes Signing-Secret. Ordne das Secret der URL zu, die Stripe tatsächlich aufruft. |
Der Rohbody-Fix, Framework für Framework
Ursache Nr. 1 ist fast immer diese: Dein Web-Framework hat das JSON gelesen, geparst und neu serialisiert, bevor constructEvent es zu sehen bekam. Neu serialisiertes JSON hat andere Leerzeichen, also passt der HMAC nicht mehr. So gibst du jedem Framework die unveränderten Bytes.
| Framework | Warum der Body verändert wird | Die Lösung |
|---|---|---|
| Express | express.json() parst den Rohtext appweit und verwirft ihn. | express.raw({type:'application/json'}) auf der Webhook-Route, vor express.json(). |
| Next.js App Router | Route Handler parsen den Body nicht für dich. | const body = await request.text() — dieser String ist bereits roh. |
| Next.js Pages Router | Der eingebaute bodyParser schreibt den Body um. | export const config = { api: { bodyParser: false } }, dann den Stream puffern. |
| Flask | request.json / request.form verbrauchen den Stream und kodieren ihn neu. | payload = request.get_data() — Bytes, bevor du .json anfasst. |
| Django | Ein Zugriff auf request.POST sperrt den Body. | request.body — zuerst lesen, vor jedem Formularzugriff. |
| AWS Lambda / API Gateway | API Gateway kann Base64-kodieren, oder ein Mapping Template schreibt den Body um. | Lambda-Proxy-Integration verwenden; wenn event.isBase64Encoded, event.body vor der Prüfung dekodieren. |
// Die Webhook-Route MUSS vor express.json() kommen
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}
);
app.use(express.json()); // alles andereexport async function POST(req: Request) {
const body = await req.text(); // roh, unverändert
const sig = req.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
return Response.json({ received: true });
}import { buffer } from 'micro';
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
const raw = await buffer(req);
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET
);
res.json({ received: true });
}Füg die exakten Bytes in den Debugger oben ein: Passt die Signatur erst, wenn du einen abschließenden Zeilenumbruch oder ein neu kodiertes Feld entfernst, wurde der Body unterwegs verändert — nicht dein Secret.
Testmodus vs. Live-Modus: das Secret, das richtig aussieht, es aber nicht ist
Stripe gibt dir drei verschiedene whsec_-Werte, und man verwechselt sie leicht. Erstens das Secret des Test-Endpoints, das im Dashboard angezeigt wird, solange du im Testmodus bist. Zweitens das Secret des Live-Endpoints — ein separater Wert am selben Endpoint, auf Live umgeschaltet. Drittens das CLI-Secret, das stripe listen ausgibt, wieder ein anderes und nur für Events gültig, die diese Session weiterleitet. Alle drei erzeugen einen plausibel aussehenden HMAC, der Fehler ist also derselbe generische No signatures found — nichts sagt Modus-Verwechslung. Das Erkennungszeichen: Testzahlungen werden verifiziert, echte liefern 400. Die Lösung: Lies das Secret genau von dem Endpoint, im genau dem Modus, der den Traffic empfängt.
Das 5-Minuten-Replay-Fenster, das alle vergessen
constructEvent prüft nicht nur den HMAC — es lehnt jedes Event ab, dessen t=-Timestamp älter als 300 Sekunden ist, und wirft Timestamp outside the tolerance zone. Das trifft dich, wenn du ein erfasstes Event Stunden später erneut sendest oder die Uhr deines Servers abgedriftet ist. Die Signatur ist völlig gültig; es ist das Alter, das scheitert. Der Debugger oben zeigt das Alter des Timestamps, damit du ein veraltetes Replay von einer echten Signaturabweichung unterscheiden kannst. Brauchst du wirklich ein längeres Fenster, nimmt constructEvent ein viertes Argument tolerance in Sekunden — aber korrigiere zuerst die Uhr.
Dein Handler oder dein Secret? Erzeuge ein garantiert gültiges Event
Sagt der Debugger, dass das Secret stimmt, die Produktion aber trotzdem 400 liefert, liegt der Bug darin, wie dein Handler den Body liest. Grenze ihn ein: Unser Signer erzeugt eine von Stripe signierte Payload mit einem Secret, das du kontrollierst, sodass du ein garantiert gültiges Event direkt per POST an deinen lokalen Handler schicken kannst. Klappt es, ist dein Verifikationscode in Ordnung, und das Problem ist das Secret oder die Middleware. Scheitert es, verändert der Handler den Body — zurück zur Rohbody-Tabelle oben.
Diagnose-Checkliste
Geh diese Punkte der Reihe nach durch, wenn constructEvent das nächste Mal eine Exception wirft.
- Füg das fehlschlagende whsec_, den Rohbody und Stripe-Signature in den Debugger oben ein — er nennt die Ursache, bevor du weiterliest.
- Stell sicher, dass der Body, der bei constructEvent ankommt, der unveränderte Roh-String ist und kein geparstes und neu serialisiertes Objekt.
- Kopiere das Signing-Secret direkt von der Seite des Endpoints im Dashboard — nicht aus stripe listen, nicht von einem anderen Endpoint.
- Prüfe, dass du für Live-Traffic das Live-Secret und für Test-Traffic das Test-Secret verwendest.
- Logge req.headers['stripe-signature'] und stell sicher, dass der Header vorhanden ist und nicht von einem Proxy abgeschnitten wurde.
- Prüfe, dass der t=-Timestamp des Events nicht älter als 5 Minuten ist (veraltetes Replay oder Uhrendrift).
Das echte fehlschlagende Event erfassen und erneut abspielen
Kommst du immer noch nicht weiter? Richte Stripe auf ein Webhook-Toolkit-Relay oder eine Capture-URL, fang genau den Request ab, der scheitert, und inspiziere Rohbody und Headers Byte für Byte — dann spiel ihn so oft gegen deinen Handler ab, wie du willst, ohne auf ein weiteres Live-Event zu warten.
Häufige Fragen
Warum funktioniert mein Stripe-Webhook lokal, aber nicht in Produktion?
Fast immer der Rohbody. Lokal mit stripe listen leitet die CLI unveränderte Bytes weiter, aber deine Produktions-Middleware (Express express.json(), ein bodyParser im Pages Router, ein Mapping Template in API Gateway) parst das JSON und serialisiert es vor der Prüfung neu. Gib constructEvent den rohen Request-Body, und es klappt.
Was bedeutet „No signatures found matching the expected signature for payload“?
Der HMAC, den du berechnet hast, passt zu keinem der v1=, die Stripe gesendet hat. Zwei übliche Ursachen: Der Body wurde vor der Prüfung verändert, oder das Signing-Secret ist falsch (oft das whsec_ der CLI statt das des Endpoints). Füg alle drei in den Debugger oben ein, und er sagt dir, welche.
Kann ich dasselbe Signing-Secret für Test- und Live-Modus verwenden?
Nein. Jeder Endpoint hat ein eigenes whsec_ für Test und für Live, und die CLI gibt ein drittes aus. Ein Test-Secret erzeugt einen plausibel aussehenden HMAC, der bei Live-Events trotzdem scheitert, mit demselben generischen Fehler. Nimm das Secret des Modus, der den Traffic empfängt.
Wie behebe ich „Timestamp outside the tolerance zone“?
Die Signatur ist gültig, aber das t= des Events ist älter als 300 Sekunden — meist ein erneut abgespielter Capture oder eine abgedriftete Server-Uhr. Korrigiere die Uhr oder spiel ein frisches Event ab. Als letzten Ausweg übergib eine größere tolerance (in Sekunden) als viertes Argument an constructEvent.
Wird v0 im Stripe-Signature-Header zur Prüfung verwendet?
Nein. constructEvent prüft nur v1= (HMAC-SHA256 von t.rawBody). v0= ist ein veraltetes Test-Schema, das Stripe noch sendet, das die Library aber ignoriert. Matcht dein selbstgebauter Verifier gegen v0, ist genau das dein Bug.
Sendet dieser Stripe-Signatur-Debugger mein Secret irgendwohin?
Nein. Der HMAC wird in deinem Browser mit WebCrypto berechnet. Dein whsec_, Body und Header verlassen den Tab nie — nichts wird hochgeladen, geloggt oder gespeichert. Trenn die Netzwerkverbindung, und es funktioniert trotzdem.