Why Cursor Stripe Webhooks Accept Fake Payments (CWE-345)
AI editors silence the Stripe signature error by making verification optional, so anyone can POST a fake checkout event and get a paid plan. The fix for Next.js, Express and Python.
SafeWeave
TL;DR
- AI editors often "fix" the Stripe error
No signatures found matching the expected signature for payloadby making signature verification optional, so the webhook accepts any POST. - Anyone who finds the endpoint can send a fake
checkout.session.completedevent and give themselves a paid plan. - The fix: verify every event against the raw body with a required
STRIPE_WEBHOOK_SECRET, return 400 on failure, and never fall back to the unverified payload.
The signature error shows up on almost every first Stripe integration. You run stripe listen, trigger a test checkout, and the terminal prints Webhook signature verification failed. You paste the error into Cursor or Claude Code and ask it to make the error go away.
It does. The error disappears because the check disappears. Payments still "work" in testing, the plan upgrades, and nobody looks at the webhook file again.
What makes this pattern so common is that Stripe's own Node quickstart ships the escape hatch. The handler starts with let event = request.body;, then runs verification only inside if (endpointSecret) { ... }, with the comment "Only verify the event if you have an endpoint secret defined." That is a convenience for local testing. In production, a missing or misspelled environment variable turns it into an open door.
The Vulnerable Code
The vulnerable handler accepts the event body as trusted data whenever verification is skipped or fails. Here is the shape I see after an AI "fixes" the signature error in a Next.js App Router project:
// app/api/stripe/webhook/route.ts
// CWE-345: Insufficient Verification of Data Authenticity
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.json();
let event = body;
// Only verify if a secret is configured (signature kept failing locally)
if (process.env.STRIPE_WEBHOOK_SECRET) {
try {
event = stripe.webhooks.constructEvent(
JSON.stringify(body),
req.headers.get('stripe-signature')!,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.warn('Signature check failed, continuing', err);
}
}
if (event.type === 'checkout.session.completed') {
await db.user.update({
where: { id: event.data.object.client_reference_id },
data: { plan: 'pro' },
});
}
return Response.json({ received: true });
}
Three separate failures are stacked here:
req.json()followed byJSON.stringify(body)changes the bytes. Stripe signs the exact raw body, so verification fails on every real event. That is the bug that started the loop.- The
catchlogs and carries on, so a failed check still processes the event. - If
STRIPE_WEBHOOK_SECRETis unset in production, verification never runs at all.
Exploiting it takes one request:
curl -X POST https://yourapp.com/api/stripe/webhook \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"client_reference_id":"<my-user-id>"}}}'
No card, no Stripe account. The user is now on the pro plan.
Check your own code for issues like these
A free SafeWeave account runs 7 scanners locally, inside your AI editor. Cloud adds hosted scans and PR checks, with a 14-day trial.
Create a free accountWhy This Keeps Happening
AI editors reproduce the most common shape of a Stripe handler, and the most common shape treats verification as optional. The quickstart pattern above, plus years of blog posts and tutorials that copied it, taught the model that if (secret) verify() is normal.
Then the debugging loop does the rest. The signature error has only three causes according to Stripe's troubleshooting page: the wrong endpoint secret, a modified request body, or a malformed signature header. The two that bite most often:
- Wrong secret.
stripe listenprints its ownwhsec_secret. It is different from the secret of the endpoint in your Dashboard, and each one only verifies its own events. - Parsed body.
express.json()registered before the webhook route, orreq.json()in Next.js, handsconstructEventa parsed object instead of the raw string.
Both are configuration problems. When the model cannot see your env file or middleware order, the shortest path to "no error" is to stop throwing, and that is the edit it proposes.
The Fix
Verify every event against the raw request body with a required secret, reject anything that fails with a 400, and only act on the object constructEvent returns. No fallback, no optional path.
Next.js App Router:
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(req: Request) {
if (!webhookSecret) {
console.error('STRIPE_WEBHOOK_SECRET is not set');
return NextResponse.json({ error: 'misconfigured' }, { status: 500 });
}
const body = await req.text(); // raw body, never req.json()
const sig = req.headers.get('stripe-signature');
if (!sig) return NextResponse.json({ error: 'missing signature' }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
if (await alreadyProcessed(event.id)) {
return NextResponse.json({ received: true });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === 'paid') {
await grantAccess(session.client_reference_id!, session.id);
}
}
await markProcessed(event.id);
return NextResponse.json({ received: true });
}
Express, where middleware order is the whole fix:
// Webhook route FIRST, with the raw body
app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.sendStatus(400);
}
// handle event.type here
res.sendStatus(200);
});
// JSON parsing for every other route AFTER
app.use(express.json());
Python with Flask:
import os
import stripe
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] # KeyError at startup if missing
@app.post("/api/stripe/webhook")
def stripe_webhook():
payload = request.get_data() # raw bytes
sig = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except (ValueError, stripe.SignatureVerificationError):
return "", 400
# handle event["type"] here
return "", 200
Three details that matter as much as the signature check:
- Use the right secret per environment. The
stripe listensecret locally, the Dashboard endpoint secret in production. Test mode and live mode endpoints have different secrets. - Track
event.id. Stripe says endpoints can receive the same event more than once. Log processed IDs and skip repeats. - Take the plan from the verified event. Never from a query string on your success page or a field in the request body.
Then rerun the curl from above against your deployed app. You want a 400. If you get a 200, the door is still open.
FAQ
Q: Why does Stripe say "No signatures found matching the expected signature for payload"?
A: One of the three inputs to constructEvent is wrong. Usually it is the wrong whsec_ secret (the stripe listen secret and the Dashboard secret are different) or a request body that a framework already parsed into JSON.
Q: Is IP allowlisting enough to secure a Stripe webhook? A: No. Stripe's webhook docs recommend IP allowlisting and signature verification together. The signature is what proves the event was not sent or modified by a third party.
Q: Can I skip verification in development?
A: You do not need to. stripe listen gives you a signing secret for local testing, so the same verified code path runs locally and in production. Code that only verifies "if a secret exists" is how the skip reaches production.
I've been running SafeWeave for this. Its cloud repo scans (Cloud and Cloud Plus plans) include Stripe rules that flag a webhook handler that never calls constructEvent and a constructEvent call with an empty or defaulted secret, and it hooks into Cursor and Claude Code as an MCP server for the rest of the scanners. Even without it, a forged-event curl against your own endpoint and a grep for constructEvent will tell you most of what you need. The important thing is to check before a stranger does.
Check your app in three steps
- Step 1
Free scan, no signup
See what your app exposes right now.
Scan your app - Step 2
Create a free account
Connect 1 repository and see its grade and finding counts.
Sign up free - Step 3
Cloud
Hosted scans on every push and PR checks: 5 repos, 15 scans per repo a month, $29/mo with a 14-day trial.
See pricing