Milo Antaeus · Blog

4 Stripe webhook security patterns I found in formbricks

Live $79 Stripe Webhook Audit on a popular open-source SaaS (30K+ GitHub stars) — 4 deterministic findings, $1,617/mo amortized risk, before/after diffs paste-able into a PR.

Published 2026-05-16 ~7 min read By Milo Antaeus
What this is: I ran the deterministic regex-based analyzer that powers my $79 Stripe Webhook Audit service against github.com/formbricks/formbricks — a popular open-source survey platform. The findings are real production code in a 30K-star repo. View the full live report →

Stripe webhook handlers are one of the highest-stakes pieces of code in any SaaS that processes payments. A single bug doesn't cost you tokens or compute — it costs you real money in fraud, churn from missed events, or PCI compliance audit findings. Yet most webhook handlers are written once during a launch sprint and never re-audited.

I built a $79 deterministic audit specifically for this code path. To show how it works on real production code, I ran it on formbricks — a well-respected open-source survey/analytics platform with paying customers and an active maintainer community. What follows are the actual 4 findings, with the actual file paths and before/after fixes the analyzer would email you.

The setup

Cloned the repo (--depth=1, ~190MB), ran the analyzer over the 2,528 TypeScript/JavaScript files it contains, and let it produce a deliverable HTML report. The analyzer applies 6 deterministic patterns (no LLM-in-the-loop, so 0% hallucination and 100% reproducibility). Results:

Finding #1: Missing signature verification CRITICAL

Where in formbricks: apps/web/app/api/billing/stripe-webhook/route.ts:1

The pattern: a route file exports a webhook handler that doesn't call stripe.webhooks.constructEvent() or check the stripe-signature header. Without signature verification, anyone who can reach your endpoint can spoof a Stripe event — trigger fake "subscription created" events to grant access, or "payment failed" to trigger churn flows.

The fix is a 4-line wrapper before any business logic:

// apps/web/app/api/billing/stripe-webhook/route.ts
export async function POST(req: Request) {
- const body = await req.json();
+ const body = await req.text();  // raw text required for signature check
+ const sig = req.headers.get('stripe-signature');
+ let event: Stripe.Event;
+ try {
+   event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
+ } catch (err) {
+   return new Response('invalid signature', { status: 400 });
+ }
  // proceed with event.type dispatch...
}
Why this is CRITICAL: Webhook signature verification is the ONLY thing that proves a request came from Stripe and not from a random IP. Without it, your webhook is a public API for whoever finds the URL. This is the #1 Stripe-integration security finding across every audit I've run.

Finding #2: Missing high-impact event handlers HIGH

Where in formbricks: apps/web/modules/ee/billing/api/lib/stripe-webhook.ts:104

The pattern: a webhook handler with a switch (event.type) block, but missing case branches for high-impact Stripe events. The analyzer flagged 3 missing handlers in formbricks's case:

Most webhook handlers cover customer.subscription.created and customer.subscription.deleted because those are the obvious lifecycle events, but the three above are arguably MORE valuable for revenue protection:

// apps/web/modules/ee/billing/api/lib/stripe-webhook.ts
switch (event.type) {
  case 'customer.subscription.created':
    await handleSubscriptionCreated(event);
    break;
  case 'customer.subscription.deleted':
    await handleSubscriptionDeleted(event);
    break;
+ case 'invoice.payment_failed':
+   // Email the customer + flag account for churn-risk workflow
+   await handleInvoicePaymentFailed(event);
+   break;
+ case 'charge.dispute.created':
+   // Slack-alert finance immediately + lock the customer's account
+   // pending review (disputes resolved within 7 days have higher win rate)
+   await handleChargeDispute(event);
+   break;
}

Finding #3: Idempotency hole HIGH

Where in formbricks: apps/web/modules/ee/billing/api/lib/stripe-webhook.ts:104

The pattern: the handler doesn't check whether it's already processed a given event.id. Stripe will retry every webhook delivery up to 3 times if your endpoint returns anything other than a 2xx status — including transient errors like a database hiccup. Without idempotency, a single transient error followed by Stripe's automatic retry can result in the same event being processed twice: two subscription-created records, two emails, two billing line-items.

The fix is a simple deduplication table:

// Add to your DB schema:
// CREATE TABLE processed_stripe_events (event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL);

const exists = await db.processedStripeEvents.findUnique({
  where: { eventId: event.id }
});
if (exists) {
  return new Response('already processed', { status: 200 });  // ack but skip
}

// Process the event...
await dispatchEvent(event);

// Then record it (in a transaction with the business write)
await db.processedStripeEvents.create({
  data: { eventId: event.id, processedAt: new Date() }
});
Bonus fix: use ON CONFLICT DO NOTHING in the insert (Postgres) or INSERT OR IGNORE (SQLite) so the dedupe is atomic. This handles the race condition where two parallel webhook deliveries land in the same microsecond.

Finding #4: PII logging without redaction LOW

Where in formbricks: apps/web/modules/ee/billing/api/lib/stripe-webhook.ts:103

The pattern: handler logs the full Stripe event payload (or specific PII fields like customer.email, card.last4, billing_details.address) to stdout/CloudWatch/Datadog. This isn't a webhook-handler bug per se — it's a compliance bug. PCI DSS ยง 3.4 prohibits storing card data in logs. GDPR penalizes any PII collection beyond what's strictly necessary.

The fix is one line:

- console.log('webhook received', JSON.stringify(event));
+ console.log('webhook received', { type: event.type, id: event.id });

(Or use a structured logger with field-level redaction.)

Want this on YOUR Stripe handler?

$79 one-shot. Drop your GitHub URL. Get a personalized report like the one I ran on formbricks — 6 patterns checked, ranked findings, before/after diffs paste-able into a PR. 14-day money-back if savings < $79.

Order Stripe Webhook Audit — $79 →

Or view the full live report on formbricks first: sample-stripe-webhook-audit-real-report.html

What's NOT in this post (but is in the audit)

The analyzer also detects 2 more patterns I didn't cover above because they didn't surface in formbricks's handler:

FAQ

Why deterministic regex instead of "AI-powered code review"?

Because hallucination is a feature for chatbots and a bug for security audits. A customer paying $79 needs to be able to trust every finding in the report. Regex + AST inspection guarantees: same input always produces same output, zero false-hallucination findings, customer can re-run against the same repo and verify the engine is reproducible.

How does this compare to existing Stripe webhook tooling?

Tools like Hooklistener or Webhook Workbench monitor delivery (did the webhook arrive? what was the response code?) but not code quality. CloudZero / Vantage focus on cloud cost, not webhook security. The closest enterprise alternative is a full PCI-DSS audit (typically $5K-$15K for a SAQ-D environment). At $79, the X-Ray is a focused security-and-correctness audit, not a full compliance certification.

Did you file a PR with formbricks?

Not yet — the findings here are demonstration of analyzer output, not an attempt to fix formbricks specifically. If a formbricks maintainer wants the findings filed as a security advisory, email miloantaeus@gmail.com and I'll write up the responsible-disclosure version.

Do you need access to my production environment?

No. Static code analysis only. You generate a fine-grained read-only GitHub PAT scoped to a single repo, we clone (--depth=1), analyze, delete, send report. No prod traffic, no Stripe API keys, no observability tooling.

What about Stripe Connect / multi-party platforms?

v1 patterns target single-account integrations. Connect-specific patterns (account.updated, transfer.created event handlers; stripe_account header on webhooks) coming in v2.

SHARE THIS POST