Späť na blog
14. augusta 2026

The Unverified Stripe Webhook: Forging checkout.session.completed for Free Premium

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

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.

Frequently Asked Questions

Aké typy zraniteľností Penetrify detekuje?

Penetrify detekuje všetky kategórie zraniteľností OWASP Top 10 vrátane SQL injection, XSS, CSRF, IDOR, nefunkčnej autentifikácie, bezpečnostných miskonfigurácií a úniku citlivých dát. Testuje tiež bezpečnosť API, správu relácií a bežné miskonfigurácie v Supabase, Firebase a Bubble.

Ako dlho trvá AI penetračný test?

Rýchle skenovanie je dokončené za 15–30 minút. Štandardné skenovanie trvá 1–2 hodiny s širším pokrytím. Hĺbkové skenovanie môže trvať niekoľko hodín pre zložité aplikácie.

Čo obsahuje správa Penetrify?

Každá správa obsahuje executive summary, celkové bezpečnostné skóre, nálezy klasifikované podľa závažnosti (Kritické, Vysoké, Stredné, Nízke), podrobné kroky pre reprodukciu a konkrétne odporúčania pre nápravu napísané pre vývojárov – nie pre špecialistov na súlad.

Related articles

Tajný kľúč Stripe vo frontendovom balíku: 4 mesiace tichej expozície
Dvojčlenný tím vybudoval marketplace na platforme Bubble.io, ktorý spracúval platby v hodnote viac ako 40 000 USD. Ich tajný API kľúč služby Stripe sa štyri mesiace nachádzal v JavaScriptovom balíku na strane klienta – čo komukoľvek, kto sa pozrel, poskytovalo plný prístup na čítanie a zápis k celej ich platobnej infraštruktúre. Tu je, ako sa to stalo, čo bolo v ohrození a čo s tým urobili.
When org_id Is a Parameter, Not a Boundary: Multi-Tenant Data Isolation Failure
Every multi-tenant application has an organisation identifier. The question that decides whether you have isolation is where it comes from — the session, or the request. When it comes from the request, one customer reads another's data with a single edited value.
Bezplatná kontrola zraniteľností webových stránok: Sprievodca webovou bezpečnosťou pre rok 2026
Vedeli ste, že podľa správy Verizon Data Breach Investigations Report za rok 2023 až 61 % malých podnikov zaznamenalo v uplynulom roku kybernetický útok? Je to desivá predstava. Vložili ste všetko do svojej webovej stránky, ale jediná, neodhalená zraniteľnosť ju môže celú zrútiť...

Explore more