Stripe webhook security test: can someone fake a payment to your app?
Your Stripe dashboard can look completely normal while your app is being told, by someone who is not Stripe, that a payment happened. Here's what that attack looks like, and why HTTPS doesn't stop it.
An unsigned webhook attack is one curl command
Your app almost certainly has a route like /api/webhooks/stripe that listens for events — “checkout completed,” “subscription cancelled,” and so on — and updates a user’s access based on what it receives. If that route doesn’t verify the request actually came from Stripe, anyone who can guess the URL (which is easy — it’s rarely secret) can send it whatever they want:
curl -X POST https://yourapp.com/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{
"type": "checkout.session.completed",
"data": { "object": { "customer": "cus_theirRealAccountId", "payment_status": "paid" } }
}'If your handler reads that JSON and flips a row to plan = 'pro' without checking who actually sent it, this works. No card, no charge, no money — just a request shaped like the real thing.
Why “it's HTTPS” doesn't mean “it's verified”
HTTPS protects the connection between whoever is sending the request and your server — it stops someone in the middle from reading or altering traffic. It says nothing about who is on the other end. Anyone with the URL can open an HTTPS connection to your server just as easily as Stripe can.
The actual fix is a signature check: Stripe signs every webhook payload with a secret only you and Stripe know (the webhook signing secret from your Stripe dashboard), and your handler is supposed to verify that signature before trusting anything in the body. Skipping that check — often because it was fiddly to wire up and the webhook “worked” in testing without it — is the whole gap.
The idempotency problem: a real webhook, replayed
Even a correctly signed webhook has a second failure mode. Stripe (and anyone who intercepts or re-sends a genuine event) can deliver the same “payment succeeded” event more than once — that’s expected behavior, not a bug on Stripe’s side. If your handler credits an account every time it sees that event type, instead of checking whether it has already processed that specific event id, a single real payment can be applied twice: two months of access for one month paid, or a credit balance that doubles.
A 5-minute self-check before you automate it
- Open your webhook handler’s source and look for a call to Stripe’s
constructEvent(or your framework’s equivalent). If it’s missing, or the raw request body is being parsed as JSON before the signature check runs, the check is broken or bypassed. - Confirm the signing secret it uses comes from an environment variable set to the correct Stripe endpoint’s secret — a stale secret from a deleted endpoint fails silently in a way that’s easy to miss.
- Check whether the handler records the event id it just processed anywhere (a table, a cache) before it does the same lookup on the next event. If there’s no record, a replay will be processed twice.
P1 and P2: both tried against your handler, in Stripe test mode
Reading the code tells you what should happen. Solacess sends the actual events, in Stripe’s test mode, and reports only what your handler actually did with them.
Both run entirely in Stripe test mode against a test event your scan creates — never a real charge, never a real customer’s data. A P1 or P2 finding always ships with $ at risk, based on the plan price the check actually saw get applied.
See a forged webhook finding, with the request and response that proved it.
The sample report is a real scan of a deliberately vulnerable app — nine findings, the proof behind each one, and what they would cost.