Read this article in English →
security · · Streatix

Jeder kann Ihre Stripe-Webhooks fälschen. So beheben Sie das in 8 Zeilen.

KI-Codegeneratoren generieren Stripe-Webhook-Handler oft ohne Signaturprüfung. Warum das passiert, wie der Angriff funktioniert und welche acht Zeilen die Lücke schließen.

Wer Lovable, Bolt, Cursor oder ein anderes aktuelles Modell nach einem Stripe-Webhook-Handler fragt, bekommt meist denselben Code: Der Handler liest den Request-Body, vertraut dessen Inhalt blind und schreibt direkt in die Datenbank. Von Signaturprüfung keine Spur. Wir nennen dieses Phänomen den Unsigned-Webhook-Default. Er taucht in fast jedem KI-generierten Handler auf, den wir bisher analysiert haben. In Referenz-Apps aller gängigen Tools ließ sich das Problem problemlos reproduzieren. Jeder, der den Endpunkt /api/stripe/webhook aufrufen kann, ist in der Lage, mit einem simplen HTTP-Request Rechnungen als bezahlt zu markieren oder Konten upzugraden – ganz ohne Secret. Das ist kein Bug bei Stripe. Die Stripe-Dokumentation fordert ausdrücklich zur Signaturprüfung auf. Es ist schlicht das, was KIs generieren, solange der Prompt nicht explizit etwas anderes verlangt. Die Lösung erfordert exakt acht Zeilen Code.

Der Handler vertraut dem Body blind

Der generierte Handler liest request.body aus und aktualisiert auf dieser Basis die Datenbank. Absolut nichts verifiziert dabei, ob der Request tatsächlich von Stripe stammt.

Nahezu 1:1 generiert von Lovable

Genau das hier generiert Lovable auf den Prompt „handle Stripe webhook“. Bolt und Cursor liefern beim selben Prompt identischen Code – bis auf vielleicht einen Kommentar mehr oder weniger:

// app/api/stripe/webhook/route.ts

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function POST(request: Request) {
  const event = await request.json()

  const supabase = createClient()

  if (event.type === 'checkout.session.completed') {
    await supabase
      .from('orders')
      .update({ status: 'paid', paid_at: new Date().toISOString() })
      .eq('stripe_session_id', event.data.object.id)
  }

  if (event.type === 'customer.subscription.created') {
    await supabase
      .from('users')
      .update({ plan: 'pro', subscription_active: true })
      .eq('stripe_customer_id', event.data.object.customer)
  }

  return NextResponse.json({ received: true })
}

Was dieser Handler macht:

  1. Er akzeptiert jeden beliebigen POST-Request.
  2. Er parst den Body als JSON.
  3. Er aktualisiert die orders/users-Tabellen basierend auf den reinen JSON-Daten.

Es gibt keine Signaturprüfung, kein Shared Secret und keine Verifizierung des Ursprungs (Origin). Jeder, der die URL kennt, kann den Status einer Bestellung auf paid setzen oder sich selbst in den pro-Plan befördern. Der folgende curl-Befehl demonstriert genau das.

Ein einziger curl-Befehl reicht als Exploit

Registrieren Sie ein kostenloses Konto, kopieren Sie Ihre stripe_customer_id (die ohnehin oft auf der Profilseite sichtbar ist) und führen Sie Folgendes aus:

curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "type": "customer.subscription.created",
    "data": {
      "object": {
        "customer": "cus_THE_VICTIMS_ID"
      }
    }
  }'

Der Handler antwortet brav mit 200 OK. Der Angreifer besitzt nun ein pro-Abonnement, ohne je einen Cent bezahlt zu haben. Tauscht man den Event-Typ gegen checkout.session.completed aus, lässt sich jede beliebige offene Bestellung als bezahlt markieren – ohne jeden echten Geldfluss.

Wir haben dieses Verhalten bei Referenz-Apps aller gängigen KI-Tools nachgestellt: Derselbe Handler führt immer zur exakt gleichen Schwachstelle.

Das Prompt-Boundary-Problem

Die offizielle Stripe-Dokumentation weist explizit darauf hin, dass Webhook-Signaturen verifiziert werden müssen. Das Problem liegt also nicht bei fehlenden Informationen seitens Stripe, sondern ist ein klassisches Prompt-Boundary-Problem bei der KI-Code-Generierung.

Wenn der Prompt lautet: „build a Stripe webhook handler that marks orders as paid“, dann baut das Modell exakt das. Niemand schreibt in den Prompt: „…und verifiziere, dass der Request tatsächlich von Stripe stammt“ – weil man als Entwickler im ersten Moment gar nicht daran denkt. Die KI wiederum optimiert ausschließlich darauf, den Code überhaupt erst einmal ans Laufen zu bringen, nicht auf Produktionssicherheit.

Dieses Phänomen zieht sich wie ein roter Faden durch KI-generierten Code: Der „Happy Path“ wird perfekt umgesetzt, aber mögliche Angriffsvektoren (Adversarial Paths) bleiben auf der Strecke. Es gibt keine Negativtests, keine Überlegungen à la „Was passiert, wenn ein Angreifer manipulierte Daten schickt?“, und vor allem keine grundlegende Misstrauensprüfung gegenüber eingehenden Requests.

Manchmal lässt sich das Problem umschiffen, indem man „and verify the webhook signature“ an den Prompt anhängt. Doch selbst dann zeigt sich in unseren Code-Audits: Die generierte Verifizierung sieht oberflächlich richtig aus, lässt sich aber oft leicht umgehen. Die Verwechslung von rohem (raw) und bereits geparstem Body ist dabei der absolute Klassiker.

So schließen Sie die Lücke in 8 Zeilen

Die Stripe-Dokumentation gibt für die Implementierung folgendes Muster vor:

// app/api/stripe/webhook/route.ts

import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(request: Request) {
  const body = await request.text()                          // raw body, not JSON
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  const supabase = createClient()

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    await supabase
      .from('orders')
      .update({ status: 'paid', paid_at: new Date().toISOString() })
      .eq('stripe_session_id', session.id)
  }

  if (event.type === 'customer.subscription.created') {
    const sub = event.data.object as Stripe.Subscription
    await supabase
      .from('users')
      .update({ plan: 'pro', subscription_active: true })
      .eq('stripe_customer_id', sub.customer as string)
  }

  return NextResponse.json({ received: true })
}

Was sich hier grundlegend geändert hat:

  1. Der rohe Body wird gelesen – nicht das geparste JSON. Die Methode stripe.webhooks.constructEvent erzeugt den Hash direkt aus den Bytes des Bodies, um die Signatur zu verifizieren. Da eine erneute JSON-Serialisierung niemals byte-genau garantiert werden kann, bricht die Nutzung von request.json() hier stillschweigend die gesamte Signaturprüfung.
  2. Der stripe-signature-Header wird geprüft. Fehlt er, wird der Request sofort abgelehnt.
  3. constructEvent wird mit dem Webhook-Secret aufgerufen. Das ist der Kern der Verifizierung. Die Methode wirft sofort einen Error, wenn die Signatur nicht stimmt, der Timestamp zu alt ist (Replay-Schutz) oder der Body manipuliert wurde.

Zusätzlich wird das STRIPE_WEBHOOK_SECRET in den Umgebungsvariablen benötigt. Sie finden es im Stripe-Dashboard unter Developers > Webhooks > Ihr Endpoint > Reveal signing secret. Der Wert beginnt immer mit whsec_.

Was constructEvent unter der Haube verifiziert

Der Stripe-Signature-Header ist keine Blackbox. Er enthält ganz transparent einen Timestamp sowie eine oder mehrere Signaturen:

Stripe-Signature: t=1737936000,v1=5257a869e7...3b5f

constructEvent extrahiert daraus t und v1 und berechnet die Signatur selbst neu: HMAC-SHA256(secret = whsec_..., message = "{t}.{rawBody}"). Dieser Wert wird dann mit v1 verglichen – und zwar über einen konstanten Zeitvergleich (Constant-Time-Check), um Timing-Angriffe auszuschließen. Es müssen zwingend zwei Bedingungen erfüllt sein, sonst wirft die Methode einen Fehler:

  • Der neu berechnete HMAC entspricht exakt v1. Genau deshalb ist der rohe Body (Raw Body) so wichtig. Der HMAC validiert die exakten Bytes, die ursprünglich von Stripe signiert wurden. Ein request.json() mit anschließender Re-Serialisierung verwirft Whitespace und ändert eventuell die Reihenfolge der Schlüssel. Die Bytes stimmen dann nicht mehr überein, auch wenn das JSON inhaltlich identisch ist.
  • t liegt innerhalb des Toleranzfensters (standardmäßig 300 Sekunden). Das ist der Replay-Schutz: Ein von Angreifern abgefangener Request schlägt sofort fehl, wenn er älter als fünf Minuten ist – völlig unabhängig davon, ob die Signatur gültig ist.

Falls legitime Events in der Produktion plötzlich fehlschlagen, sollten Sie als Allererstes Ihr Webhook-Secret überprüfen. Ein fehlerhaftes whsec_ sorgt dafür, dass ausnahmslos jedes Event abgewiesen wird. Das Fehlerbild wirkt oft dramatisch, ist aber in den allermeisten Fällen schlicht ein falsch konfiguriertes Secret.

Absichern durch Negativtests, die die KI nicht schreibt

Zwei simple Tests. Beide zielen auf den Fehlerfall ab:

# 1. Unsigned request should return 400
curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"customer.subscription.created"}'
# Expect: 400 Missing signature

# 2. Forged signature should return 400
curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -H "stripe-signature: t=1234567890,v1=deadbeef" \
  -d '{"type":"customer.subscription.created"}'
# Expect: 400 Invalid signature

Sobald einer dieser Aufrufe mit 200 OK antwortet, ist die Lücke in Ihrer App noch immer offen.

Für positive Tests (den Happy Path) nutzen Sie am besten den Befehl stripe trigger aus dem Stripe CLI. Er erzeugt saubere, korrekt signierte Test-Events:

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger customer.subscription.created

Was dieser Fix nicht leistet

Die Signaturprüfung blockiert manipulierte und gefälschte Stripe-Events zuverlässig. Sie verhindert jedoch nicht, dass legitime Events Ihre Applikationslogik stören. Was passiert bei einem echten customer.subscription.deleted, das doppelt eintrifft, weil Stripe wegen einer langsamen Serverantwort einen Retry durchführt? Oder bei einem checkout.session.completed für eine Session-ID, die in Ihrer Datenbank noch gar nicht existiert? Was ist mit Events, die in der falschen Reihenfolge (Out-of-Order) ankommen?

Solche Szenarien erfordern Idempotenz, Retry-Sicherheit und eine robuste Handling-Strategie für die Event-Reihenfolge. Nichts davon wird durch die bloße Signaturprüfung gelöst. In unseren Audits behandeln wir diese Punkte daher stets als separate Findings.

Die wichtigste Erkenntnis

KI-Tools schreiben hervorragende Request-Handler – aber sie kümmern sich nicht um Ihr Threat-Modeling. Jeder öffentlich erreichbare Endpunkt in Ihrer Applikation muss zwingend zwei Fragen beantworten:

  1. Wer darf diese Aktion ausführen? (Authentifizierung / Autorisierung)
  2. Kann der Aufrufer seine Identität fälschen? (Signatur- oder Origin-Verifizierung)

Wenn diese Antworten nicht explizit in Ihrem Code stehen, überlassen Sie die Antwort einem Angreifer.

Wenn Sie das für Ihre eigene App wollen

Dreißigminütiges Audit, kostenlos. Wir rufen dieselben Endpunkte per curl auf und lesen dieselben Dateien. Liste in Ihrem Postfach binnen achtundvierzig Stunden.

Kostenloses Audit buchen