Skip to content
DemoA made-up account, acme. Its deep scan of acme/api replays on a loop, sped up. Nothing here is real or saved.Back to the siteGet started
Agent Cernis
highhigh confidenceCWE-345authenticationOpen

Stripe webhook is acted on before its signature is checked

src/webhooks/stripe.ts:31Exploit verified

Impact

Anyone who can reach the webhook URL can mark invoices paid or cancel subscriptions by posting a forged event. The handler runs the side effects before it checks the signature, and an exception in the check is swallowed.

AttackerAn unauthenticated caller on the internet
Crossesthe payment provider's signature
Getssubscription and payment state

Attack path

3 steps

Entry point: POST /webhooks/stripe, public by design.

  1. Step 1: src/webhooks/stripe.ts:26Entry
    The raw body is parsed as JSON first.
    25const event = JSON.parse(req.body.toString());
  2. Step 2: src/webhooks/stripe.ts:28
    Side effects run on the parsed event.
    27await handlers[event.type]?.(event.data.object);
  3. Step 3: src/webhooks/stripe.ts:31Sink
    Only now is the signature checked, inside a try that logs and continues.
    30try {
    31 stripe.webhooks.constructEvent(req.body, sig, secret);
    32} catch (err) { log.warn(err); }

Proof of concept

Exploit narrative
POST an invoice.paid event with any invoice id and no Stripe-Signature header. The invoice flips to paid and the response is 200.

Exploit verified via runtime replay: An unsigned invoice.paid body was accepted and applied.

Fix

Verify first with the raw body, reject on failure with 400, and only then dispatch the verified event object.

The fix must guarantee

  • No handler runs for an event whose signature does not verify

It must not break

  • Retries from the provider are still idempotent
Fix prompt for your coding agent

Paste into Claude Code, Cursor, or any coding agent. It carries the attack path, the guarantees, and the regression test.

In acme/api src/webhooks/stripe.ts, move stripe.webhooks.constructEvent above the dispatch, return 400 when it throws, and dispatch the event it returns instead of JSON.parse(req.body).