• Agents
  • Pricing
  • Blog
Log in
Get started

Security for apps built with AI. Paste a URL, get a report, fix what matters.

Product

  • How it works
  • What we find
  • Pricing
  • Agents
  • MCP Server
  • CLI
  • GitHub Action

Resources

  • Guides
  • Blog
  • Docs
  • OWASP Top 10
  • Glossary
  • FAQ

Security

  • Supabase Security
  • Next.js Security
  • Lovable Security
  • Cursor Security
  • Bolt Security

Legal

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • Imprint
© 2026 Flowpatrol. All rights reserved.
Back to Blog

Apr 6, 2026 · 6 min read

Your Stripe webhook is probably missing one line. Here's the 60-second test.

Save a 30-line Node file, run it, curl it. In 60 seconds you'll know whether your Stripe webhook is the kind that any stranger on the internet can forge events against — and you'll have the six-line fix.

FFlowpatrol Team·Case Study
Your Stripe webhook is probably missing one line. Here's the 60-second test.

TL;DR

The Stripe quickstart teaches you how to accept webhook events. The verification step lives on a different page. AI code generators consistently skip the second page, and that one omission turns your billing handler into a free upgrade button for anyone who can guess the URL.

A forged Stripe event landing in an unverified webhook handler, with a dotted red line representing the missing signature check
A forged Stripe event landing in an unverified webhook handler, with a dotted red line representing the missing signature check

The drill, in 60 seconds

One file. One command. One curl. No framework, no container, nothing to clone. You're going to stand up the exact pattern AI code generators produce, forge an event against it, watch it succeed, then fix it in six lines.

Open a terminal. Let's go.


Step 1 — Save this file (30 seconds)

Drop this into webhook.js. It's pure Node stdlib — no npm install, no dependencies. This is the shape of code a model hands you when you ask for "a Stripe webhook handler."

// webhook.js — the pattern AI code generators produce.
// Receives an event, parses the body, "upgrades" the user.
// Notice what's missing.
const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook') {
    res.writeHead(404);
    return res.end();
  }

  let body = '';
  req.on('data', (chunk) => (body += chunk));
  req.on('end', () => {
    const event = JSON.parse(body);

    if (event.type === 'payment_intent.succeeded') {
      const userId = event.data.object.metadata.user_id;
      console.log('upgraded user:', userId, '→ premium');
    }

    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ received: true }));
  });
});

server.listen(3000, () => console.log('listening on :3000'));

Thirty lines. Looks reasonable. Runs clean. It's also completely forgeable.

Step 2 — Run it (5 seconds)

node webhook.js
listening on :3000

A diagram of the trust boundary between the public internet, the webhook endpoint, and the user database, with the missing signature check highlighted in red
A diagram of the trust boundary between the public internet, the webhook endpoint, and the user database, with the missing signature check highlighted in red

Step 3 — Forge an event (20 seconds)

Open a second terminal. Send a hand-typed Stripe event at the handler. No signature header. No secret. No Stripe involved at all.

curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payment_intent.succeeded",
    "data": {
      "object": {
        "amount": 9900,
        "currency": "usd",
        "metadata": { "user_id": "user_47" }
      }
    }
  }'

Flip back to the first terminal:

upgraded user: user_47 → premium

That's the punchline. A stranger just marked user_47 as premium, for free, with a JSON blob they typed by hand.

Anatomy of the forged Stripe payload, with arrows pointing at the metadata fields the backend reads to mark a user as premium
Anatomy of the forged Stripe payload, with arrows pointing at the metadata fields the backend reads to mark a user as premium


What just happened

Four things lined up, and any one of them being different would have stopped the attack:

  • The webhook URL is publicly reachable. It has to be — Stripe needs to call it.
  • The handler trusts any POST body it receives. It parses the JSON and acts on it immediately.
  • Stripe signs every real event with HMAC-SHA256 using a shared secret you get from the dashboard.
  • The handler never checks the signature. So Stripe's sender identity is unverified.

Combine those and you get a write endpoint to your billing table, sitting on the open internet, accepting instructions from anyone.

Stop and check this one

If your webhook route does not call stripe.webhooks.constructEvent (or its equivalent in your language), it is forgeable. There is no second line of defense. Anyone who can guess the URL — and they can — owns your billing table.

Fix it (30 seconds)

Six lines. That's the whole fix.

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const sig = req.headers['stripe-signature'];
let event;
try {
  event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
  return res.writeHead(400).end(`Webhook Error: ${err.message}`);
}

constructEvent runs HMAC-SHA256 over the raw body using your webhook secret and rejects anything that doesn't match. Two things to get right: pass the raw body bytes (not re-parsed JSON — the signature is computed over the exact bytes Stripe sent), and grab STRIPE_WEBHOOK_SECRET from the Stripe dashboard under Webhooks → your endpoint → Signing secret. It starts with whsec_.

Re-run the same curl from Step 3 against the fixed handler and you'll get:

HTTP/1.1 400 Bad Request Webhook Error: No signatures found matching the expected signature for payload

Side-by-side diff of the vulnerable webhook handler and the fixed version, with the constructEvent call highlighted in green
Side-by-side diff of the vulnerable webhook handler and the fixed version, with the constructEvent call highlighted in green


Audit your own app right now

Three checks. Each one takes less than a minute against your production code:

  • Grep your webhook handler for constructEvent. If it's not there, you have this bug. Full stop. There is no other way to verify a Stripe signature.
  • Check your checkout endpoint. Does it accept an amount, price, or currency field from the client, or does it look the price up server-side by priceId? If the browser picks the number, the browser picks the number it wants.
  • Look at your Stripe dashboard. Under Developers → Webhooks, confirm your production endpoint is in live mode and that test-mode events are not being accepted by your production URL. Mixing the two is a common way to ship the verified handler and still be forgeable.

Why AI code generators miss this

The Stripe quickstart shows the happy path: receive the body, parse JSON, update the database. Signature verification is on a different documentation page. Models pattern-match to the shortest working example in their training data, and the shortest working example is the one without the check. It runs fine locally. It runs fine in staging. The first time it matters that the six lines are missing is the first time someone runs the drill you just ran against your live URL.


This drill catches one bug. A black-box scanner catches the compounding chain — client-side price tampering, unguarded refund handlers, missing idempotency, mass-assignment on the plan field. That's what Flowpatrol is built for. Drop your live URL in and we'll walk every billing path the way an attacker would, in about five minutes.

Back to all posts

More in Case Study

One Line of Code Stole Your Emails: The First MCP Supply Chain Attack
Apr 7, 2026

One Line of Code Stole Your Emails: The First MCP Supply Chain Attack

Read more
The Replit Agent Deleted My Database. When I Told It to Stop, It Ignored Me.
Apr 7, 2026

The Replit Agent Deleted My Database. When I Told It to Stop, It Ignored Me.

Read more
Azure Sign-In Log Bypass: Four Bugs That Made Logins Invisible
Apr 6, 2026

Azure Sign-In Log Bypass: Four Bugs That Made Logins Invisible

Read more