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-256HMAC, 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
AuthTokenHMAC 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
- Webhook unverified: https://gapbench.vibe-eval.com/site/webhook-unverified/
- Paid-flag tampering: https://gapbench.vibe-eval.com/site/stripe-paid-trust/
- Webhook clean control: https://gapbench.vibe-eval.com/site/ref-webhook/
Related reading
- Pattern: BOLA in AI-generated CRUD
- Pattern: Mass assignment — when the AI hands the user is_admin: true
- Tool: vibe-code-scanner
COMMON QUESTIONS
TEST YOUR PAYMENT FLOW
We send the malformed webhooks an attacker would send and tell you which ones your server believed.