Retour au blog
14 août 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

Quels types de vulnérabilités Penetrify détecte-t-il ?

Penetrify détecte toutes les catégories de vulnérabilités OWASP Top 10, notamment les injections SQL, XSS, CSRF, IDOR, les failles d'authentification, les mauvaises configurations de sécurité et l'exposition de données sensibles. Il teste également la sécurité des API, la gestion des sessions et les mauvaises configurations courantes dans Supabase, Firebase et Bubble.

Combien de temps dure un test de pénétration IA ?

Un scan rapide se termine en 15–30 minutes. Un scan standard dure 1–2 heures avec une couverture plus large. Un scan approfondi peut durer plusieurs heures pour les applications complexes.

Que contient un rapport Penetrify ?

Chaque rapport comprend un résumé exécutif, un score de sécurité global, des résultats classés par gravité (Critique, Élevé, Moyen, Faible), des étapes de reproduction détaillées et des recommandations de remédiation concrètes rédigées pour les développeurs – pas pour les responsables conformité.

Related articles

La clé secrète Stripe dans le bundle frontend : 4 mois d'exposition silencieuse
Une équipe de deux personnes a créé une place de marché Bubble.io traitant plus de 40 000 $ de paiements. Leur clé secrète d'API Stripe était restée dans le bundle JavaScript côté client pendant quatre mois — donnant à quiconque l'aurait consultée un accès complet en lecture/écriture à toute leur infrastructure de paiement. Voici comment cela s'est produit, ce qui était en jeu et ce qu'ils ont fait pour y remédier.
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.
Scan de vulnérabilités de site web gratuit : Le guide 2026 de la sécurité web
Saviez-vous que, selon le rapport 2023 de Verizon sur les enquêtes relatives aux violations de données (Data Breach Investigations Report), un pourcentage stupéfiant de 61 % des petites entreprises ont subi une cyberattaque l'année dernière ? C'est une perspective terrifiante. Vous avez investi corps et âme dans votre site web, mais une simple vulnérabilité non détectée pourrait suffire à tout faire s'écrouler…

Explore more