Zpět na blog
14. srpna 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

Jaké typy zranitelností Penetrify detekuje?

Penetrify detekuje všechny kategorie zranitelností OWASP Top 10, včetně SQL injection, XSS, CSRF, IDOR, broken authentication, bezpečnostních misconfigurations a expozice citlivých dat. Testuje také bezpečnost API, správu relací a běžné misconfiguration v Supabase, Firebase a Bubble.

Jak dlouho trvá AI penetrační test?

Rychlý sken je dokončen za 15–30 minut. Standardní sken trvá 1–2 hodiny s širším pokrytím. Hloubkový sken může pro komplexní aplikace trvat několik hodin.

Co obsahuje zpráva Penetrify?

Každá zpráva obsahuje executive summary, celkové bezpečnostní skóre, nálezy klasifikované dle závažnosti (Kritická, Vysoká, Střední, Nízká), kroky pro reprodukci a konkrétní doporučení pro nápravu napsaná pro vývojáře – ne pro compliance manažery.

Related articles

Tajný klíč Stripe ve frontendovém balíčku: 4 měsíce tiché expozice
Dvoučlenný tým vytvořil tržiště na platformě Bubble.io, které zpracovávalo platby v hodnotě přes 40 000 USD. Jejich tajný klíč API služby Stripe byl čtyři měsíce uložen v balíčku JavaScriptu na straně klienta – což dávalo komukoli, kdo se podíval, plný přístup pro čtení i zápis k celé jejich platební infrastruktuře. Zde je, jak se to stalo, co bylo ohroženo a co s tím udělali.
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ý test zranitelností webu: Průvodce zabezpečením webových stránek pro rok 2026
Věděli jste, že podle zprávy Verizon's 2023 Data Breach Investigations Report zažilo neuvěřitelných 61 % malých firem v uplynulém roce kybernetický útok? Je to děsivá představa. Vložili jste vše do svých webových stránek, ale jediná, neodhalená zranitelnost by mohla všechno zničit…

Explore more