ShipWatchFree scan

Is your Stripe webhook verifying signatures? (AI builders often skip it)

If your webhook grants access when it receives checkout.session.completed, anyone who finds the URL can send that event themselves unless the handler checks Stripe's signature. Here's how to test yours and the few lines that fix it.

Short answer: Every Stripe webhook request carries a Stripe-Signature header. Pass the raw request body, that header and your endpoint's whsec_ signing secret to stripe.webhooks.constructEvent (or constructEventAsync on Deno), return 400 if it throws, and only then act on the event.

This is the server half of a Stripe integration. If the problem is a secret key in your frontend, start with Stripe secret key exposed instead.

The problem

A webhook endpoint is a public URL. Stripe POSTs events to it, but so can anyone else with curl. The only thing that proves a request came from Stripe is the Stripe-Signature header: a timestamp plus an HMAC of the raw body, computed with your endpoint's signing secret. Stripe's own docs say that without verification an attacker can send fake events to fulfil orders, grant account access or modify records.

AI-generated handlers skip this in a few predictable ways:

Check it yourself [manual]

  1. Find the handler. Search your code for webhook, checkout.session.completed or Stripe-Signature. If constructEvent doesn't appear, it isn't verifying.
  2. Read the branch logic. Any path where the event is used without a successful constructEvent call is a bypass, including an if (endpointSecret) guard.
  3. Test your own endpoint with an unsigned request. Only do this against an app you own:
    curl -i -X POST https://yourapp.com/api/stripe-webhook \
      -H "Content-Type: application/json" \
      -d '{"id":"evt_test","type":"checkout.session.completed","data":{"object":{}}}'
    A verifying handler answers 400. A 200, or a change in your database, means it trusts unsigned events.

Fix it: Node (Express)

Get the signing secret from the endpoint in Workbench (Reveal secret) or from stripe listen for local testing. Both start with whsec_, and they are different values. Store it server-side as STRIPE_WEBHOOK_SECRET. Mount the webhook route with a raw body parser, before any express.json():

const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is not set"); // fail closed

app.post("/api/stripe-webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], secret);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }
  // Only now is event trustworthy.
  if (event.type === "checkout.session.completed") { /* grant access */ }
  res.sendStatus(200);
});
app.use(express.json()); // other routes

In a Next.js App Router route handler, read the body with await req.text() and pass that string to constructEvent.

Fix it: Supabase Edge Function (Deno)

Deno uses the Web Crypto API, which is async, so use constructEventAsync with Stripe's SubtleCrypto provider. This is adapted from Supabase's official Stripe webhook example:

import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_API_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

Deno.serve(async (req) => {
  const signature = req.headers.get("Stripe-Signature");
  const body = await req.text(); // raw body, not req.json()
  let event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, signature!, Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!, undefined, cryptoProvider);
  } catch (err) {
    return new Response("Invalid signature", { status: 400 });
  }
  // handle event.type here
  return Response.json({ received: true });
});

Stripe can't send a Supabase user JWT, so this function is deployed with JWT verification turned off. That makes the signature check the only gate, which is exactly why it can't be skipped.

Audit my Stripe webhook handler. It must read the raw request body (no JSON parsing before verification), call stripe.webhooks.constructEvent (or constructEventAsync with Stripe.createSubtleCryptoProvider() on Deno) with the Stripe-Signature header and STRIPE_WEBHOOK_SECRET, return 400 if verification throws, and refuse to start if the secret is missing. Remove any code path that uses the event without verifying it. Make fulfilment idempotent by recording processed event IDs.

Make it stick

What the free scan checks here (and what it doesn't)

ShipWatch doesn't check webhooks, signature verification or any server-side code; that part is [manual]. What it does check: Stripe sk_live_ and rk_live_ keys plus OpenAI, Anthropic, AWS, Google and Supabase secret keys in your HTML and up to 25 linked scripts, exposed /.env and /.git/HEAD, HTTPS, and four security headers.

FAQ

What happens if my Stripe webhook doesn't verify signatures?
Anyone who learns the URL can POST a made-up event, such as checkout.session.completed for a user who never paid, and your code will act on it. Stripe's docs warn that attackers can use fake events to fulfil orders or grant account access.
Why does constructEvent fail even with the right secret?
Usually the body was parsed before verification. Stripe signs the exact raw bytes it sent, so express.json() or any re-serialised JSON breaks the check. Also confirm you're using the whsec_ secret for this endpoint; CLI and Dashboard secrets differ.
Does the free scan test my Stripe webhook?
No. ShipWatch doesn't check webhooks or your server code. It reads your live page and up to 25 linked scripts for leaked keys, including Stripe sk_live_ and rk_live_ secret keys, and checks /.env, /.git/HEAD, HTTPS and four security headers.

Scan your app free in 30 seconds

Paste your live URL for a plain-English report with fix prompts. If you keep shipping, weekly monitoring re-checks it: Solo $19/mo or Pro $49/mo.

Scan my app free