Torna al Blog
14 agosto 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

Quali tipi di vulnerabilità rileva Penetrify?

Penetrify rileva tutte le categorie di vulnerabilità OWASP Top 10, inclusi SQL injection, XSS, CSRF, IDOR, autenticazione compromessa, configurazioni di sicurezza errate ed esposizione di dati sensibili. Testa anche la sicurezza delle API, la gestione delle sessioni e le comuni configurazioni errate in Supabase, Firebase e Bubble.

Quanto dura un test di penetrazione con IA?

Una scansione rapida si completa in 15–30 minuti. Una scansione standard dura 1–2 ore con una copertura più ampia. Una scansione approfondita può durare diverse ore per applicazioni complesse.

Cosa include un report di Penetrify?

Ogni report include un sommario esecutivo, un punteggio di sicurezza complessivo, i risultati classificati per gravità (Critico, Alto, Medio, Basso), procedure di riproduzione dettagliate e indicazioni concrete di rimediazione scritte per gli sviluppatori, non per i responsabili della conformità.

Related articles

La Chiave Segreta di Stripe nel Bundle Frontend: 4 Mesi di Esposizione Silenziosa
Un team di due persone ha realizzato un marketplace su Bubble.io che elaborava pagamenti per oltre 40.000 dollari. La loro chiave API segreta di Stripe era rimasta nel bundle JavaScript lato client per quattro mesi, consentendo a chiunque avesse accesso di ottenere accesso completo in lettura e scrittura all'intera infrastruttura di pagamento. Ecco come è successo, cosa era a rischio e cosa hanno fatto al riguardo.
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.
Scansione Gratuita Vulnerabilità Siti Web: La Guida 2026 alla Web Security
Sapevi che, secondo il Data Breach Investigations Report 2023 di Verizon, un impressionante 61% delle piccole imprese ha subito un attacco informatico nell'ultimo anno? È un pensiero terrificante. Hai investito tutto nel tuo sito web, ma una singola vulnerabilità non individuata potrebbe farlo crollare…

Explore more