Payment integrations concentrate risk in one endpoint that most teams treat as plumbing: the webhook receiver. It is publicly reachable by necessity, it is authenticated by a mechanism you have to implement yourself, and what it does is grant paid access. That combination makes it one of the highest-value targets in a SaaS application, and one of the least tested.
The failure is not exotic. The handler reads the JSON body, trusts the event type, and updates the subscription.
The Handler That Costs You Revenue
// vulnerable
app.post('/webhooks/stripe', async (req, res) => {
const event = req.body; // trusted, unverified
if (event.type === 'checkout.session.completed') {
await upgradeAccount(event.data.object.client_reference_id, 'pro');
}
res.sendStatus(200);
});
Nothing about that code is unusual — it mirrors the shape of the documentation example minus one step. And the missing step is the only thing standing between your pricing page and a free upgrade:
curl -X POST https://api.example.com/webhooks/stripe \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed",
"data":{"object":{"client_reference_id":"attacker-user-id",
"amount_total":9900,"payment_status":"paid"}}}'
There is no payment. There is no Stripe. There is an HTTP request that your application chose to believe.
The Four Ways Verification Goes Wrong
Absent. The common case, usually because verification was skipped during local development — signature checks are awkward against a tunnel — and never added back. The code works in production, so nothing prompts the change.
Parsed body. Stripe's signature is computed over the raw request body. Any middleware that parses JSON before the verifier runs invalidates the comparison, so developers hit a verification failure and reach for the fix that makes it pass: removing the check. The correct fix is to give that one route the raw body.
No timestamp tolerance. The signature header includes a timestamp, and verifying the digest without checking the age accepts a replay. A captured legitimate event can then be resent indefinitely — useful for extending a cancelled subscription, or for triggering an action repeatedly.
Test-mode secret in production. Verification passes, but against the test signing secret, so anyone can generate valid events using publicly available test-mode tooling. This one is particularly unpleasant because it looks correct in code review and in logs.
What an Attacker Gets
Directly: a paid plan, seat count, credit balance or feature flag — whatever your entitlement logic derives from the event. If your product sells usage, that is revenue loss proportional to how much they take.
Indirectly, and often worse: state corruption. Forged invoice.payment_failed events can downgrade or lock other users' accounts if the handler resolves the target from the payload, which turns a billing bug into a denial-of-service against your customers. Forged customer.subscription.deleted does the same. Any handler that acts on an identifier supplied in the event is a cross-account write primitive.
Then the accounting mess. Your database says a customer is on Pro; Stripe says they never paid. Reconciliation finds it eventually, and in the meantime your revenue reporting is wrong and your support team is arguing with a customer who is technically correct.
How to Test Your Own Endpoint
Send an unsigned event and read the status code:
curl -si -X POST https://api.example.com/webhooks/stripe \
-H 'Content-Type: application/json' \
-d '{"type":"checkout.session.completed","data":{"object":{"id":"cs_test_forged"}}}' \
| head -1
Anything other than a 4xx is a finding. Then check the three subtler cases: send a valid signature with a timestamp from an hour ago to test replay tolerance; send an event whose customer identifier belongs to a different account to test whether the handler resolves the target from the payload or from your own records; and confirm which signing secret production actually uses.
Finally, confirm the endpoint is idempotent. Stripe retries, so a handler that grants credits on every delivery of the same event ID hands out multiples to anyone who can trigger a retry — which does not require forgery at all.
The Fix
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // raw body, this route only
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET // live secret in production
);
} catch {
return res.sendStatus(400);
}
if (await alreadyProcessed(event.id)) return res.sendStatus(200); // idempotency
// resolve the target from your own records, not from the payload
await handle(event);
await markProcessed(event.id);
res.sendStatus(200);
});
Four properties matter and all four are easy to lose: verify against the raw body, use the live signing secret, enforce the timestamp tolerance the SDK gives you by default, and store processed event IDs so a replay is a no-op. Then resolve the affected account from your own database using the Stripe customer ID you stored at signup, rather than trusting an identifier the request supplied.
For the entitlement itself, the sturdier pattern is not to trust events at all: treat the webhook as a signal to re-fetch subscription state from Stripe's API and reconcile. Then a forged event causes an unnecessary API call rather than a free upgrade.
Why This Endpoint Gets Missed
It is not in your UI, so nobody clicks it during QA. It is not in your authenticated API surface, so tests that iterate endpoints with a session token skip it. It is documented as an integration detail rather than as an authorisation boundary, and the SDK makes the insecure version shorter than the secure one.
The same reasoning applies to every provider, not only Stripe: payment processors, identity providers, CI systems and any SaaS that calls you back. The rule generalises — a public endpoint that changes state must verify who sent the request, and "it came over HTTPS to a URL only they know" is not verification. We wrote about the other half of this integration failing; the two together are why payment paths deserve testing on every release rather than once a year.
