STRIPE TRUST ON THE WRONG SIDE

There are two Stripe bugs AI codegen produces almost interchangeably. Skipping the webhook signature check, and trusting a is_paid flag the client controls. Both end the same way — free pro plans, free credits, free everything.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate. The client engagement that originally surfaced this pattern is anonymized; the gapbench scenario is the reproducible equivalent.

The free Pro plan

A founder I know shipped a SaaS in three weekends. Stripe integration, monthly subscription, pro tier with extra features. Three months in, he noticed he had way more pro users than he had charges. By “way more” I mean ~60% of the pro tier had no Stripe customer record. The frontend was unlocking pro features based on a flag in the user’s profile, and that flag was being set by a webhook handler that didn’t verify Stripe signatures. Someone had figured this out and posted on a Discord server. Other people followed.

The fix took an hour. The damage was already done — months of giveaways, a database to rebuild, and a non-trivial chunk of customers asking for refunds when they realized the audit was happening. The bug was, technically, two lines.

Two flavors

Bug one — webhook unverified

The Stripe webhook handler is supposed to look like this:

import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

app.post('/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}`)
  }
  // ... handle event
})

The line that matters is stripe.webhooks.constructEvent. It recomputes the signature over the raw body and throws if it doesn’t match. Without that line — or with req.body already parsed to JSON, which silently breaks the signature — your endpoint is just a free POST endpoint. Anyone who knows the URL and the event shape can fire a fake checkout.session.completed and your downstream handler will dutifully grant the customer the thing.

We see two failure variants. First, the AI omits the verification entirely because the developer asked for “a webhook handler” and the example the model trained on didn’t include the verify step. Second, the AI includes the verify step but uses express.json() instead of express.raw(), so the body is already parsed by the time constructEvent sees it. Verification fails on every request, the developer disables it, and the bug is shipped.

Live example: https://gapbench.vibe-eval.com/site/webhook-unverified/. POST a fake event. The server processes it.

Bug two — paid-flag tampering

The other shape. Frontend has a state called isPro. When the user upgrades, the frontend flips the flag and sends an API call. The API persists it. There’s also a Stripe webhook somewhere that does the right thing on real payments. But the API endpoint that the frontend calls doesn’t check with Stripe — it trusts the body of the request.

app.patch('/api/me', requireAuth, async (req, res) => {
  await db.user.update({
    where: { id: req.session.userId },
    data: { isPro: req.body.isPro }   // <-- attacker controls this
  })
  res.json({ ok: true })
})

Open DevTools, intercept the PATCH, change isPro: false to isPro: true, hit replay. Frontend now thinks you’re pro. Backend confirms. Everything that gates on isPro unlocks. You did not pay.

Live example: https://gapbench.vibe-eval.com/site/stripe-paid-trust/.

A specific incident

Anonymized. A B2B tool with a Stripe-driven subscription. Pro tier unlocked unlimited integrations; free tier capped at three. The bug: the user’s plan field was updated by both the Stripe webhook and a /api/me PATCH endpoint, and the PATCH endpoint trusted whatever came in.

The discovery story is what makes it interesting. A customer-success rep was demoing the product to a prospect. The prospect said “I’d love to test the Pro features but my company won’t approve a credit card until end of quarter.” The rep, helpful, opened the prospect’s account in the admin panel and flipped the plan to Pro. That worked — admins were allowed to do that. What the rep didn’t know was that the panel called the same /api/me endpoint that anyone with a session could call. A few months later, a community member discovered the same endpoint and posted on a Reddit thread.

By the time the team caught it, ~340 users had the Pro plan without a Stripe customer record. About 200 of them looked legitimate (rep escalations, friends-and-family, support comp). About 140 didn’t. The team had three uncomfortable months of: refunding the legitimate ones who paid later, downgrading the rest, and emailing each downgrade individually to explain.

The fix was the smaller of two: either remove the field from the PATCH allowlist, or check Stripe at every read. They chose remove from allowlist plus a CI check that any PATCH that touches plan, tier, is_pro, subscription_status fails the build unless it’s the webhook handler. We see this pattern often enough that we recommend it as a default — paid-state fields go on a deny-by-default allowlist for any non-webhook write.

The webhook signature check, in painful detail

Stripe’s signature scheme is straightforward in the docs and easy to break in practice. The breakage modes:

// WRONG: body is JSON-parsed before reaching the verifier
app.use(express.json())  // <- parses the whole app's JSON
app.post('/webhook/stripe', (req, res) => {
  const event = stripe.webhooks.constructEvent(req.body, sig, secret)
  // constructEvent expects the raw body string
  // req.body is now an object, signature won't match
})
// WRONG: try/catch swallows the verification error and proceeds
try {
  event = stripe.webhooks.constructEvent(req.body, sig, secret)
} catch (err) {
  console.log('verification failed:', err)
  // No early return! Code below still runs!
}
processEvent(event)  // event is undefined here, but if it WAS the parsed body, this would still run
// RIGHT: raw body for the webhook route only, error path returns
app.post('/webhook/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, secret)
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`)
    }
    processEvent(event)
    res.json({ received: true })
  }
)

The Stripe CLI’s stripe listen --forward-to localhost:3000/webhook/stripe is the fastest way to verify your handler is correct. It sends real signed events, and your handler should accept them. If the CLI throws on every event but your prod webhooks “work” — that’s a sign your prod isn’t verifying anything.

Cross-stack and cross-platform notes

This pattern isn’t Stripe-specific. The same shape applies to:

  • Paddle, LemonSqueezy, Lago, Chargebee. All have signature-verification endpoints. All have the same “raw body required” gotcha. AI codegen reproduces the bug across all of them.
  • GitHub webhooks. X-Hub-Signature-256 HMAC, signed with the webhook secret. AI-generated GitHub Actions integrations frequently skip the verify step.
  • Slack webhooks. Signing secret + timestamp + body, hashed. Same vulnerability surface.
  • Twilio webhooks. Auth via shared AuthToken HMAC over the URL + params. AI codegen omits the verify step about as often as for Stripe.

The general rule: any webhook that arrives over HTTPS with a signature header is a webhook the developer is supposed to verify. If the verify step is missing from the handler, treat it as a critical finding.

How we detect both

The webhook variant is a runtime test. We POST a malformed event to the webhook URL with a missing or wrong signature, and watch what the server does. If the response indicates success, or if downstream state changes (we can usually detect this by re-fetching the affected user’s profile), the bug is real.

The paid-flag variant is also a runtime test, but it requires a session. We sign up, fetch the user’s profile, find any field that looks plan-related, and PATCH it back with the value flipped. If the server accepts the change without consulting Stripe — observable because Stripe has no record of the upgrade — that’s the finding.

Static scanners can flag both shapes if they look for them, but they can’t confirm either one without a request. The verification has to be live.

Fix

For the webhook: use stripe.webhooks.constructEvent on the raw body. Use express.raw({ type: 'application/json' }) for the route specifically, not the global JSON parser. Test with the Stripe CLI’s stripe listen --forward-to localhost:3000/webhook/stripe — it sends real signed events and surfaces verification failures immediately.

For the paid-flag: don’t accept plan state from the client. Read it from your database, where it was set by a verified webhook. The frontend’s isPro should be a display of the truth, not a source of it. If you need the client to display state quickly after an upgrade, the right pattern is a Stripe Checkout redirect on success — the user comes back to your app, your server fetches their subscription from Stripe directly, and renders accordingly.

CWE / OWASP

  • CWE-345 — Insufficient Verification of Data Authenticity (webhook)
  • CWE-602 — Client-Side Enforcement of Server-Side Security (paid flag)
  • OWASP API Security Top 10 — API8:2023 Security Misconfiguration

Reproduce it yourself

COMMON QUESTIONS

01
What is the Stripe webhook signature check?
When Stripe sends your server a webhook — a customer paid, a subscription renewed, a charge failed — it includes a signature header computed with a secret only Stripe and you know. Your server is supposed to recompute the signature on the raw body and reject any request where they don't match. If you skip that check, anyone can POST a fake 'customer paid' event to your endpoint and your server will believe it.
Q&A
02
Why do AI generators skip it?
Two reasons. First, the Stripe docs example for 'getting your first webhook working' doesn't always include the signature check — the verification is a separate section. AI codegen pattern-matches on the simpler example. Second, the signature check requires the raw request body, but most frameworks parse the body to JSON before the handler runs. If you don't preserve the raw bytes, the signature will not verify, and the developer working through trial-and-error often disables the check rather than fix the body parsing.
Q&A
03
What is paid-flag tampering?
Some apps send the user's plan or paid status from the client to the server with each upgrade-related action. If the server trusts that flag instead of verifying the user's subscription state with Stripe, an attacker can flip the flag in the request and unlock paid features. We see this when AI generators put the upgrade logic in client-side state and then have the API accept a is_pro field on the request body without re-checking the source of truth.
Q&A
04
How is this different from BOLA?
BOLA is reading or modifying another user's data. This is modifying your own account state — granting yourself paid access — by lying to the server about what your account state should be. The fix is the same shape (trust the server, not the client) but the threat model is different. BOLA hits other people. This one hits revenue.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/webhook-unverified/ accepts forged Stripe webhooks. https://gapbench.vibe-eval.com/site/stripe-paid-trust/ trusts a client-supplied paid flag. The clean control for webhooks is at https://gapbench.vibe-eval.com/site/ref-webhook/.
Q&A
06
What CWE does this map to?
CWE-345 (Insufficient Verification of Data Authenticity) for the webhook variant, CWE-602 (Client-Side Enforcement of Server-Side Security) for the paid-flag variant. Both fall under OWASP API #8:2023 (Security Misconfiguration) and the broader trust-boundary class.
Q&A

TEST YOUR PAYMENT FLOW

We send the malformed webhooks an attacker would send and tell you which ones your server believed.

RUN THE SCAN