Ein Stripe-Webhook ohne Signaturprüfung ist ein offenes Scheunentor: Jeder, der die URL kennt, kann Zahlungen faken. Dieser Artikel zeigt die saubere Prüfung in Python (Flask/FastAPI) und im Cloudflare Worker, plus Replay-Schutz, Idempotenz und die häufigsten Fehler.
Warum ein Webhook ohne Signaturprüfung ein offenes Scheunentor ist
Ein Stripe-Webhook ist nichts anderes als eine öffentliche URL in deiner Anwendung, an die Stripe per HTTP POST JSON-Events schickt: checkout.session.completed, invoice.paid, customer.subscription.deleted und so weiter. Das Problem: Diese URL kennt nicht nur Stripe. Sie steht in deinen Server-Logs, taucht im Netzwerkverkehr auf und lässt sich oft erraten. Wer sie kennt, kann selbst einen POST mit erfundenem JSON schicken.
Wenn dein Code dem Body blind vertraut und bei checkout.session.completed die Bestellung auf bezahlt setzt oder ein Abo freischaltet, hat ein Angreifer gerade kostenlos eingekauft. Die Absicherung ist keine Kür, sondern Pflicht: Stripe signiert jeden Request, und du prüfst diese Signatur, bevor auch nur ein Feld aus dem Payload gelesen wird. Dieser Artikel zeigt, wie das in Python (Flask/FastAPI) und in einem Cloudflare Worker sauber funktioniert, inklusive der Fallstricke, die in der Praxis am häufigsten kosten.
Wie die Stripe-Signatur aufgebaut ist
Jeder echte Stripe-Request trägt einen Header Stripe-Signature. Er sieht ungefähr so aus: t=1699999999,v1=5257a869e7.... Das t ist ein Unix-Timestamp, v1 eine oder mehrere HMAC-Signaturen. Stripe bildet den signierten String als timestamp + "." + raw_body und berechnet darüber einen HMAC mit SHA-256. Der Schlüssel ist dein Webhook-Signing-Secret, das mit whsec_ beginnt und pro Endpoint im Dashboard steht.
Zwei Dinge sind hier entscheidend. Erstens: Signiert wird der exakte rohe Request-Body, Byte für Byte. Sobald ein Framework das JSON parst und neu serialisiert, ändert sich das Ergebnis und die Prüfung schlägt fehl. Zweitens: Es können mehrere v1-Werte vorkommen, etwa während einer Secret-Rotation. Die offiziellen SDKs behandeln das korrekt, bei einer Handimplementierung musst du selbst daran denken.
Signatur in Python prüfen (Flask und FastAPI)
In Python nimmst du das offizielle stripe-Paket und die Funktion stripe.Webhook.construct_event(). Sie berechnet den HMAC, vergleicht in konstanter Zeit und prüft gleich das Zeitfenster mit. Der wichtigste Punkt: Du musst den rohen Body übergeben, nicht das geparste JSON.
In Flask holst du den Body mit request.get_data() und den Header mit request.headers.get("Stripe-Signature"), dann rufst du auf: event = stripe.Webhook.construct_event(request.get_data(), sig_header, endpoint_secret).
Wirft die Funktion einen ValueError (kaputter Payload) oder einen stripe.error.SignatureVerificationError (falsche Signatur), antwortest du mit HTTP 400 und verarbeitest nichts. In FastAPI ist der Ablauf identisch, nur holst du den Body mit await request.body() in einer async-Route und liest den Header über request.headers. Das endpoint_secret gehört in eine Umgebungsvariable, niemals in den Code. Achtung beim lokalen Testen: stripe listen aus der Stripe-CLI gibt ein eigenes whsec_-Secret aus, das sich vom Secret des produktiven Endpoints unterscheidet.
Signatur im Cloudflare Worker prüfen
Am Edge wird es interessant, weil ein Cloudflare Worker kein Node.js-crypto-Modul hat, sondern die Web-Crypto-API (crypto.subtle). Die synchrone Variante construct_event aus dem Node-SDK funktioniert dort nicht. Stripe hat dafür constructEventAsync() zusammen mit Stripe.createSubtleCryptoProvider() nachgerüstet. Den rohen Body holst du mit await request.text(), das Secret legst du per wrangler secret put STRIPE_WEBHOOK_SECRET ab.
Wenn du ohne SDK auskommen willst, ist die manuelle Prüfung mit Web-Crypto überschaubar. Die Schritte:
- Header
Stripe-Signaturean Kommas und=zerlegen undtsowie allev1-Werte extrahieren. - Den signierten String
`${t}.${body}`als UTF-8 kodieren. - Das Secret via
crypto.subtle.importKeyals HMAC-Schlüssel mit{ name: "HMAC", hash: "SHA-256" }importieren. - Mit
crypto.subtle.sign("HMAC", key, data)signieren und das Ergebnis in einen Hex-String wandeln. - Den erwarteten Hex-Wert in konstanter Zeit gegen jeden
v1vergleichen (Byte für Byte, kein===mit Early-Exit auf den Strings).
Danach prüfst du noch, ob t innerhalb deiner Toleranz liegt. In den allermeisten Fällen ist der SDK-Weg mit constructEventAsync aber die bessere Wahl, weil er all das erledigt und die Secret-Rotation gleich mitnimmt.
Replay-Schutz und Idempotenz
Eine gültige Signatur allein reicht nicht. Wer einen echten, mitgeschnittenen Request erneut abschickt, hat eine gültige Signatur in der Hand. Deshalb gehört zum Timestamp eine Toleranz. Stripe empfiehlt und die SDKs erzwingen standardmäßig 300 Sekunden. Ein Request, dessen t älter als fünf Minuten ist, wird abgelehnt. construct_event und constructEventAsync machen diese Prüfung automatisch, bei einer Handimplementierung musst du sie selbst einbauen.
Zweite Baustelle: Stripe liefert Events garantiert mindestens einmal, aber gelegentlich auch mehrfach. Verarbeitest du invoice.paid zweimal, verschickst du zwei Rechnungen oder buchst doppelt. Die Lösung ist Idempotenz: Speichere jede verarbeitete event.id (Format evt_...) in einer Tabelle mit Unique-Constraint und brich ab, wenn die ID schon da ist. Antworte Stripe außerdem schnell mit einem 2xx-Status und schiebe die eigentliche Arbeit in einen Hintergrund-Job. Wer erst eine E-Mail verschickt und einen weiteren Dienst aufruft, bevor er antwortet, riskiert Timeouts und damit unnötige Retries von Stripe.
Die häufigsten Fehler in der Praxis
- Body-Parser frisst den Raw-Body: In Express verhindert
express.json()die Prüfung. Für die Webhook-Route brauchst duexpress.raw({ type: "application/json" }). In Flask/FastAPI nie überrequest.jsongehen, sondern den Rohbody nehmen. - Falsches Secret: Test- und Live-Modus haben getrennte
whsec_-Secrets, und die Stripe-CLI hat nochmal ein eigenes. Ein 400 mit Signaturfehler ist fast immer das falsche Secret oder ein veränderter Body. - String statt Bytes: Wird der Body vorher dekodiert, umkodiert oder getrimmt, passt der HMAC nicht mehr.
- Vergleich mit Early-Exit: Bei manueller Prüfung immer konstante Zeit nutzen (
hmac.compare_digestin Python), sonst öffnest du ein Timing-Leck. - Endpoint hinter Auth oder CSRF-Schutz: Stripe kann sich nicht einloggen und schickt kein CSRF-Token. Die Webhook-Route muss von solchen Middlewares ausgenommen sein, aber durch die Signaturprüfung geschützt bleiben.
Fazit: fünf Zeilen, die deinen Umsatz schützen
Die eigentliche Prüfung ist ein Einzeiler: construct_event in Python, constructEventAsync im Worker. Der Aufwand steckt nicht im HMAC, sondern in den Rahmenbedingungen: den rohen Body durchreichen, das richtige Secret laden, den Timestamp gegen Replays absichern und jede event.id nur einmal verarbeiten. Wer diese vier Punkte sauber umsetzt, hat einen Webhook-Endpoint, dem er Zahlungen anvertrauen kann. Alles davor ist ein offenes Scheunentor mit einem Schild, auf dem der Preis steht.