← Back to blog
Findings May 5, 2026 · 6 min read

We probed 6,000 web apps for Stripe webhook signature checks. 1,542 don't bother.

A fake Stripe event in a curl one-liner. No Stripe-Signature header. 1,542 of the apps we scanned this week returned a 200. That means anyone can forge payment events on those endpoints. Here is what we found and the six-line fix.

Last week we scanned roughly 6,000 web apps with our payment-bypass module. The module is dumb on purpose. It POSTs a minimal fake checkout.session.completed event to a list of common webhook paths and asks one question: does the server accept it without a Stripe-Signature header?

1,542 apps said yes.

That is not a typo. One in four apps with a payment-webhook-shaped URL is willing to process a forged Stripe event from any HTTP client on the internet. No auth, no signature, no replay protection.

What we actually sent

The payload is whatever Stripe's documentation says a real event looks like, minus the cryptographic proof:

POST /api/webhook/stripe HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "id": "evt_secprobe_test",
  "object": "event",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_secprobe_test",
      "payment_status": "paid",
      "amount_total": 100,
      "customer": "cus_secprobe",
      "currency": "usd"
    }
  },
  "livemode": false
}

That is it. No Stripe-Signature header. A real Stripe event arrives with a header that looks like t=1234567890,v1=hexdigest..., computed by Stripe using a secret you set up when you registered the webhook endpoint. If your server skips the signature check, it has no way to know whether the event came from Stripe, from us, or from anyone with curl.

We tried 17 common path variants per host: /api/webhooks/stripe, /api/webhook/stripe, /api/payments/webhook, /webhooks/stripe, plus the same patterns for Paddle and LemonSqueezy. If the server returned 200, 201, or 202, we counted it.

The exploit primitive

Why this matters more than a generic "missing auth" finding: Stripe webhooks are how payment status reaches the application. A typical SaaS flow looks like this:

  1. User clicks "subscribe", redirects to Stripe Checkout.
  2. User pays. Stripe redirects them back.
  3. Stripe also sends a checkout.session.completed webhook to your server.
  4. Your server reads the event, looks up the customer email or session ID, and flips that user's account from plan: free to plan: pro.

If step 4 doesn't verify the signature, anyone can fire step 4 directly. They sign up for the free tier, find the webhook URL (often documented or trivially guessable), and POST a fake event with their own customer ID. Their account upgrades. Stripe never charged them.

The variant that hits hardest: apps that look up the user from a field inside the event body (the customer email or session ID). The attacker doesn't even need a session, just the URL. Stuff their email in the fake event, hit send, get pro.

Why is this so common?

Stripe's documentation is good and the example code includes the signature check. Every major framework has a one-line library function for it. Yet a quarter of webhook endpoints we scanned don't run it.

The pattern we keep seeing in the developer journey:

  1. Build the integration locally with a stub handler that just console.logs the event body. Get the upgrade-the-user logic working. Signature verification is on the TODO.
  2. Deploy to production. The signature check is still on the TODO.
  3. It works. Real Stripe events come through. Customers pay, accounts upgrade. The TODO stays.
  4. Six months later, no one remembers it was ever a TODO.

The same pattern shows up on apps generated by code assistants. The generated route handler accepts JSON, parses the event, calls the upgrade function. Signature verification is a separate idea the developer has to remember to ask for. Most don't.

Spread by hosting platform

The 1,542 hits aren't concentrated on any one platform. Roughly half are on custom domains (production SaaS apps), half on hosted preview platforms. A few buckets:

Custom-domain SaaS apps are the most worrying bucket because they are real businesses with real Stripe accounts. The hosted-preview hits are usually less serious. Many are demo apps, half-built side projects, or test deployments. But they still expose the same code patterns the developer will copy into production.

One anonymized example

One of the cleanest hits was a hotel booking site on Render. Their webhook endpoint at /api/webhook/paddle returned 200 OK with an empty body to our forged event. The body told us nothing, but the 200 told us everything: the server accepted, parsed, and presumably acted on a Paddle event we made up.

We didn't follow up to confirm the exploit (sending a real fake-customer payload would cross from "scanning" to "actively defrauding"), but the primitive is clear. A guest could craft an event that says "this booking is paid", POST it, and the booking record flips to confirmed. The hotel sees a confirmed reservation in their dashboard. The guest never paid.

We disclosed to the operator privately. They acknowledged within 4 hours.

The six-line fix

Stripe's library does this for you. In Node:

app.post('/api/webhook/stripe',
  express.raw({type: 'application/json'}),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    // proceed with event
    res.json({received: true});
  });

Same pattern in Python with the official stripe SDK, in Ruby, in Go, etc. Three things you need:

  1. Read the Stripe-Signature header.
  2. Pass the raw request body, not the parsed JSON. Most frameworks parse JSON by default, which destroys the signature. You typically need to register a special body reader on the webhook route.
  3. Get your webhook secret from the Stripe dashboard (Developers, Webhooks, click your endpoint, "Signing secret"). Stash it in STRIPE_WEBHOOK_SECRET.

The middle step is where most people trip up. Express's default express.json() middleware will eat the raw body before your handler sees it, leaving Stripe's library to compute a signature against the parsed-and-stringified JSON, which never matches. The fix is to register express.raw({type: 'application/json'}) just on the webhook route, before any global JSON parser. FastAPI users: read await request.body() directly, not request.json().

Paddle, LemonSqueezy, and most other payment processors have the equivalent in their SDKs. If you are integrating any of them, the rule is: verify the signature before doing anything else with the payload.

Caveats and methodology

Two things worth being honest about.

First, a 200 response doesn't prove the application actually grants the user something on the back of the forged event. Some endpoints log every webhook for analytics and return 200 regardless. Others queue the event for async processing and return 200 immediately, then drop it later when validation fails. We can't know without exploiting, which we don't do. The 1,542 number is "endpoints accepting unsigned events", not "endpoints definitely upgrading the attacker's account".

Second, the 6,000 base for the percentage is the number of distinct hosts we scanned where at least one of the 17 webhook paths matched. Many apps don't have a webhook endpoint at all (no Stripe integration), so they aren't in the denominator.

What is irrefutable: 1,542 specific hosts have a payment-webhook-shaped URL that handles unsigned events with a 2xx response. That is a misconfiguration on its own, regardless of downstream behavior.

Scan your own app

The full payment-bypass module is part of our standard scan. Run it on your URL at securityscanner.dev. It hits all 17 webhook path variants and tells you exactly which ones return 200 to an unsigned event. Three minutes, free, no signup required for the quick scan.

If you find your endpoint flagged, the fix is in the snippet above. If your stack isn't represented in the snippet, search for "[your stack] stripe webhook signature verification". Every framework has the canonical example.

Run the same scan on your app

One free scan, no credit card. Works with any URL or IP — finds the issues from this post and more.

Start free