DEV Community

Cover image for Why Cursor Stripe Webhooks Accept Fake Payments (CWE-345)
Charles Kern
Charles Kern

Posted on Originally published at safeweave.dev

Why Cursor Stripe Webhooks Accept Fake Payments (CWE-345)

TL;DR

  • AI editors often "fix" the Stripe error No signatures found matching the expected signature for payload by making signature verification optional, so the webhook accepts any POST.
  • Anyone who finds the endpoint can send a fake checkout.session.completed event 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 });
}
Enter fullscreen mode Exit fullscreen mode

Three separate failures are stacked here:

  1. req.json() followed by JSON.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.
  2. The catch logs and carries on, so a failed check still processes the event.
  3. If STRIPE_WEBHOOK_SECRET is 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>"}}}'
Enter fullscreen mode Exit fullscreen mode

No card, no Stripe account. The user is now on the pro plan.

Why 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 listen prints its own whsec_ 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, or req.json() in Next.js, hands constructEvent a 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 });
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Three details that matter as much as the signature check:

  • Use the right secret per environment. The stripe listen secret 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.

Top comments (0)