Your first GitHub repository audit is free.No card. Every finding included.
aster

THE FULL GUIDE / EP 04 · ASTER × CLAUDE

Anyone can fake a Stripe “payment succeeded”. Verify your webhook

Your webhook is a doorman who signs for any package from anyone in a delivery vest. A URL isn’t a password, and “payment succeeded” is just JSON anyone can type. Here’s how to make the doorman check signatures, why the check “kept failing”, and the three moves that close the hole.

Episodes18 min readBy Aster
Voxel illustration under the headline “It’s not Stripe.”: in a night-time apartment lobby, a shocked Aster holds his laptop at his side, a deadpan orange block Claude stands beside a happy golden retriever wearing a LIFETIME PRO tag, and at the WEBHOOK front desk a doorman hands a PRO badge to a grinning man in a hi-vis vest with STRIPE scrawled on his T-shirt in marker, holding a box stamped PAYMENT SUCCEEDED.

Commented HOOK? Here’s everything we promised: the checklist, the code, and why each step matters.

Jump to a story
  1. The checklist
  2. 01What a webhook is, and why anyone can POST to it
  3. 02How a fake “payment succeeded” gets someone Pro
  4. 03Is this rare? One scan says no
  5. 04Verify the signature with constructEvent
  6. 05The raw-body trap (and the exact code per framework)
  7. 06“No signatures found matching the expected signature for payload”
  8. 07Handle each event once, and only fulfil verified, paid events
  9. 08Test it with the Stripe CLI before you ship
  10. 09“Make the error go away” is not a security fix
  11. FAQ
  12. Source notebook
01

What a webhook is, and why anyone can POST to it

Voxel illustration: a night-time apartment lobby where a long line of strangers in hi-vis delivery vests, each carrying a box stamped PAYMENT SUCCEEDED, queues at a front desk with a gold WEBHOOK plaque, while a cheerful doorman hands out gold PRO badges without looking; Aster stands on the left with his laptop, hand on his head, and the orange block Claude stands beside him, unimpressed.
In the episode, the webhook is a doorman who signs for anything from anyone in a delivery vest.

When someone pays you through Stripe Checkout, Stripe tells your server about it by sending an HTTPS POST with a JSON event to a URL you registered, your webhook endpoint. A typical one looks like https://your-app.com/api/webhook. Your code reads the event, sees checkout.session.completed, and gives the customer what they paid for.

Stripe’s own docs say webhooks are how you should fulfil orders: the customer’s browser might never make it back to your success page, and subscriptions and delayed payment methods change state long after checkout.

The catch: the door is open to the whole street

Stripe requires your endpoint to be a publicly accessible HTTPS URL. That’s the point: Stripe’s servers have to reach it. But so can everyone else. The endpoint has no idea who’s knocking unless it checks.

  • A URL isn’t a password. Paths like /api/webhook, /api/stripe/webhook and /webhooks/stripe are what every tutorial and AI assistant uses, so they’re easy to guess. They also show up in public repos, logs and browser bundles.
  • The payload is just JSON. Anyone can type {"type":"checkout.session.completed", …}. Event IDs, session IDs and amounts in the body prove nothing on their own.
  • Stripe gives you the proof. Every event Stripe sends carries a signature in the Stripe-Signature header, made with a secret only you and Stripe know. That header is the doorman’s ID check.

THE BUILDER’S TAKEAWAYYour webhook is the one door in your app that grants paid access. It has to check ID like it means it.

02

How a fake “payment succeeded” gets someone Pro

Voxel illustration: at the WEBHOOK front desk, a grinning man in a hi-vis vest with STRIPE scrawled across his white T-shirt in black marker holds up the marker and slides over a box stamped PAYMENT SUCCEEDED while the doorman pins a gold PRO badge on him; his cousin in another vest waves behind him, Aster facepalms in the foreground with his laptop at his side, and Claude stares, unimpressed.
Writing STRIPE on your shirt in Sharpie shouldn’t be enough. Without a signature check, it is.

This is how it happens. There’s no famous named heist here, because a forged webhook doesn’t look like an attack in your logs. It looks like a sale. Here is the kind of handler the episode is about, next to the fixed version:

Don’tapp/api/webhook/route.js
// app/api/webhook/route.js
export async function POST(req) {
  const event = await req.json();   // whatever anyone sent

  // It kept saying "No signatures found matching
  // the expected signature", so…
  // const sig = req.headers.get('stripe-signature');
  // event = stripe.webhooks.constructEvent(body, sig, secret);

  if (event.type === 'checkout.session.completed') {
    const email = event.data.object.customer_email;
    await db.users.update({ email }, { plan: 'pro' });
  }
  return Response.json({ received: true });
}
Doapp/api/webhook/route.ts
// app/api/webhook/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!; // whsec_…

export async function POST(req: Request) {
  const body = await req.text();   // raw, untouched
  const sig = req.headers.get('stripe-signature') ?? '';

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, secret);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Only now is this really Stripe talking.
  await handle(event);
  return Response.json({ received: true });
}

On the left, the handler trusts whatever arrives. An attacker who knows (or guesses) the URL sends one request with their own email in customer_email, and the database marks them Pro. Then their cousin does it. Then a guy who wrote STRIPE on his shirt. No card, no Stripe, no record in your Stripe Dashboard, just a new “customer” in your app.

Test your own endpoint in ten seconds

Send your own endpoint a forged event with no signature. This is the same kind of request the attacker would send, and it’s the fastest way to know where you stand:

terminal
# Send YOUR OWN endpoint a forged event, with no Stripe-Signature header.
curl -i -X POST https://your-app.com/api/webhook \
  -H "Content-Type: application/json" \
  -d '{"id":"evt_fake_1","object":"event","type":"checkout.session.completed",
       "data":{"object":{"id":"cs_fake_1","object":"checkout.session",
       "payment_status":"paid","customer_email":"you+test@example.com"}}}'

# Verified handler:   HTTP/1.1 400   (and nobody gets Pro)
# Unverified handler: HTTP/1.1 200   (check your database. Did you just upgrade a stranger?)

Only test endpoints you own. Use a throwaway test email so you can find and undo any upgrade it causes.

client.js
// ❌ Every one of these can be typed by the user.
if (localStorage.getItem('isPaid') === 'true') showProFeatures();
if (new URLSearchParams(location.search).get('paid') === '1') unlockPro();
await fetch('/api/upgrade', { method: 'POST', body: JSON.stringify({ plan: 'pro' }) });

// ✅ The server decides, from its own database, which only a verified webhook writes to.
const { plan } = await fetch('/api/me').then((r) => r.json());

THE BUILDER’S TAKEAWAYIf a request can grant Pro, assume someone other than Stripe will send it.

03

Is this rare? One scan says no

1,542apps reported accepting forged Stripe events
~6,000web apps probed in the scan
~1 in 4of the apps with payment-webhook URLs
Voxel illustration: a happy golden retriever in a tiny hi-vis vest sits proudly on the lobby floor with a gold LIFETIME PRO tag on its red collar and a small box at its paws, under a big wall screen reading 1,500+ apps reported accepting fake events; Aster stares at the screen, worried, laptop at his side, and Claude looks at the dog.
In the episode, a golden retriever got a lifetime plan. Good boy. Terrible security.

In May 2026, the security vendor securityscanner.dev reported that it probed about 6,000 web apps and found 1,542 that accepted forged Stripe webhook events. According to the vendor, it sent minimal fake checkout.session.completed events, with no Stripe-Signature header, to 17 common webhook paths per host, and counted any 200, 201 or 202 reply as accepted.

  • Treat it as a vendor claim. It’s one company’s scan, not an independent study, and a 2xx reply doesn’t prove an app actually granted anything. It does prove the handler didn’t reject an unsigned request, which a verified handler always does.
  • The pattern they describe is the one in the episode. Signature verification sits on a TODO list, or got commented out while fighting the error, and the handler ships without it. The vendor also says code generators often produce handlers without the check unless you ask.
  • It’s spread across hosts. The vendor counted about 720 on custom domains, 198 on Render and 142 on Vercel, among others.

The exact number matters less than the shape: it takes one unauthenticated request to find out, so assume someone will try yours.

THE BUILDER’S TAKEAWAYWebhook paths are predictable and the test is one request. Scanners do it at scale.

04

Verify the signature with constructEvent

Voxel illustration: the doorman, now serious, holds an envelope with a glowing lime wax seal under a scanner whose screen shows a green check and the word SIGNATURE, while a red barrier and a big red X stop a man in a hi-vis vest and a STRIPE marker shirt carrying an unsealed box; Aster gives a thumbs-up with his laptop at his side and Claude stands by the desk, satisfied.
Same lobby, new rule: no valid signature, no signature from the doorman.

Stripe signs every event it sends. The official libraries check it for you in one call. In Node, that’s stripe.webhooks.constructEvent() with three things:

ArgumentWhat it isWhere it comes from
payloadThe raw request body, a string or Buffer, byte for byteYour framework (see the next section)
headerThe value of the Stripe-Signature request headerreq.headers.get('stripe-signature')
secretYour endpoint’s signing secret, starting with whsec_Workbench → Webhooks → your endpoint → reveal, or the output of stripe listen

If the signature matches and the timestamp is recent, it returns the parsed Stripe.Event. If anything is wrong, it throws a StripeSignatureVerificationError. Catch it, return 400, and do nothing else.

What the signature actually is

The header looks like t=1492774577,v1=5257a8…,v0=6ffbb5… (on one line). Stripe computes an HMAC with SHA-256, using your endpoint secret as the key, over the string timestamp + "." + raw body. constructEvent recomputes it and compares. An attacker without your whsec_ can’t produce a matching v1, and changing even one byte of the body or the timestamp breaks it.

  • Only v1 counts. Stripe sends an extra fake v0 signature on test events; ignore every scheme except v1 to prevent downgrade attacks. The library already does.
  • Don’t hand-roll it unless you must. If you do, use a constant-time comparison and check the timestamp yourself. Stripe documents the manual steps, but recommends the official libraries.
  • whsec_ is not sk_. The signing secret verifies incoming events. Your secret API key calls Stripe. Both stay on the server, and neither goes in client code.

THE BUILDER’S TAKEAWAYOne function call is the difference between a doorman and an open door.

05

The raw-body trap (and the exact code per framework)

Voxel illustration: on the WEBHOOK desk, a box wrapped in intact lime tape with a lime padlock wax seal gets a green check under a scanner beam, while a second box that was cut open, emptied into stacks of paper and sloppily re-taped gets a red X and the words SEAL BROKEN; Aster stands behind it holding a box cutter, laptop at his side, and Claude points at the sealed box.
Open the box before the doorman checks the seal, and the seal is broken, even if everything is still inside.

This is why the check “kept failing” in the episode. The signature covers the exact bytes Stripe sent. Most frameworks helpfully parse JSON before your code runs. If you then hand constructEvent the parsed object, or JSON.stringify it back into a string, the bytes are different (whitespace, key order, escaping, number formatting) and verification fails, even though the data “looks” the same.

Stripe’s docs: any manipulation to the raw body of the request causes the verification to fail. So the rule is: read the raw body first, verify, then use event.data.object, which the library has already parsed for you.

Next.js App Router

Doapp/api/webhook/route.ts
// app/api/webhook/route.ts  (Next.js App Router)
import Stripe from 'stripe';

export const runtime = 'nodejs'; // the default; on 'edge', use constructEventAsync

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  // Read the body ONCE, as text. Don't call req.json() before this.
  const body = await req.text();
  const sig = req.headers.get('stripe-signature') ?? '';

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    console.warn('Stripe webhook rejected:', (err as Error).message);
    return new Response('Invalid signature', { status: 400 });
  }

  // event.data.object is already parsed for you. No JSON.parse needed.
  return Response.json({ received: true });
}

A request body can only be read once. If anything calls req.json() before req.text(), the raw body is gone. App Router route handlers don’t parse the body for you, so there’s no bodyParser flag to turn off here.

Next.js Pages Router

Dopages/api/webhook.ts
// pages/api/webhook.ts  (Next.js Pages Router)
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';

// Turn off Next's body parser for THIS route, so the raw bytes survive.
export const config = { api: { bodyParser: false } };

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function rawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks);
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return res.status(405).end('Method Not Allowed');
  }
  const body = await rawBody(req);
  const sig = req.headers['stripe-signature'] as string;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
  }

  // …handle event…
  res.status(200).json({ received: true });
}

Without bodyParser: false, req.body is already an object and the signature can’t match.

Express

Doserver.js
// server.js  (Express)
import express from 'express';
import Stripe from 'stripe';

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// 1. The webhook route comes FIRST, with a raw parser on this route only.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    // req.body is a Buffer here, exactly the bytes Stripe signed.
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // …handle event…
  res.json({ received: true });
});

// 2. JSON parsing for every OTHER route goes AFTER the webhook route.
app.use(express.json());
app.post('/api/other', (req, res) => { /* … */ });

app.listen(3000);

Order matters. If app.use(express.json()) runs before the webhook route, it parses the body first and verification fails. Stripe’s own Express example uses express.raw({type: 'application/json'}) on the route.

Hono, Cloudflare Workers, Deno, Bun and edge runtimes

Dosrc/index.ts
// src/index.ts  (Hono on Cloudflare Workers, Bun or Deno)
import { Hono } from 'hono';
import Stripe from 'stripe';

type Env = { STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string };
const app = new Hono<{ Bindings: Env }>();

app.post('/webhook', async (c) => {
  const stripe = new Stripe(c.env.STRIPE_SECRET_KEY);
  const body = await c.req.text();                 // raw body
  const sig = c.req.header('stripe-signature') ?? '';

  try {
    // Web Crypto is async, so use the async variant outside Node.
    const event = await stripe.webhooks.constructEventAsync(
      body, sig, c.env.STRIPE_WEBHOOK_SECRET,
      undefined, Stripe.createSubtleCryptoProvider(),
    );
    // …handle event…
    return c.json({ received: true });
  } catch (err) {
    return c.text(`Webhook Error: ${(err as Error).message}`, 400);
  }
});

export default app;

Outside Node’s crypto, verification goes through Web Crypto, which is async. Use constructEventAsync. The same applies to a Next.js route with runtime = 'edge'.

THE BUILDER’S TAKEAWAYVerify the bytes Stripe sent, not your framework’s opinion of them.

06

“No signatures found matching the expected signature for payload”

This is the message that got the check commented out. Stripe’s troubleshooting page puts it simply: if you see it, at least one of the three arguments you passed to constructEvent() is wrong. Here’s every message stripe-node can throw, and what each one means:

Error messageUsual causeFix
No signatures found matching the expected signature for payload.The body was changed before verifying (parsed and re-stringified, middleware, a proxy), or the secret is the wrong oneRaw body; the whsec_ for this exact endpoint and mode
Webhook payload must be provided as a string or a Buffer … Payload was provided as a parsed JavaScript object instead.You passed req.body after express.json(), or the result of req.json()Use express.raw() or await req.text()
No stripe-signature header value was provided.The request didn’t come from Stripe (a forged request, a curl test), or a proxy dropped the headerFor fakes, this is the check working. Otherwise forward the header
Unable to extract timestamp and signatures from headerYou passed the wrong header, or it was mangledPass stripe-signature exactly as received
Timestamp outside the tolerance zoneThe signed timestamp is older than the tolerance (5 minutes by default): a replay, a delayed relay, or your server clock is offSync the clock (NTP). Don’t raise the tolerance to hide it

The wrong-secret checklist

Stripe calls the wrong endpoint secret the most common error. Every one of these is a different whsec_:

  • Stripe CLI vs Dashboard. stripe listen prints its own secret (it stays the same between restarts). A Dashboard or API endpoint has a different one. Don’t verify CLI-forwarded events with the Dashboard secret, or the other way around.
  • Sandbox vs live. If the same URL is registered for test and live, Stripe says the secret is different for each.
  • Each endpoint. Several endpoints means several secrets. Use the one for the endpoint that’s calling you.
  • Whitespace. A newline or space pasted into the env var breaks the match. Newer stripe-node versions add a note to the error when the secret contains whitespace.
  • Rolled secrets. After Roll secret in Workbench, the old one keeps working for up to 24 hours if you chose a delay, then stops. Update your env var in that window.

Timestamp tolerance and replays

A replay attack resends a real, validly signed event that someone captured. Because the timestamp is inside the signed payload, it can’t be changed without breaking the signature, so constructEvent rejects events older than its tolerance: 300 seconds by default in stripe-node. Stripe generates a fresh timestamp and signature for every delivery attempt, so its own retries still pass.

THE BUILDER’S TAKEAWAYThe error isn’t the bug. It’s the doorman telling you the package was opened.

07

Handle each event once, and only fulfil verified, paid events

Voxel illustration: the doorman sits at the WEBHOOK desk with an open logbook listing evt_1 to evt_4, each with a green tick, and raises a red rubber stamp at the same delivery guy, back with a box now stamped ALREADY SIGNED; in the foreground Aster sits on a bench with his laptop open on his lap showing green terminal lines, the lime lock on his shirt visible, and Claude watches over his shoulder.
The doorman keeps a logbook. Same event twice? Already signed.

A verified event is real, but it can still arrive twice, out of order, or for a payment that hasn’t cleared. Stripe’s docs say webhook endpoints might occasionally receive the same event more than once, that delivery order isn’t guaranteed, and that in live mode failed deliveries are retried for up to three days with exponential back-off (three times over a few hours in a sandbox).

1. Record every event ID under a unique key

schema.sql
-- One row per Stripe event you have handled.
create table stripe_events (
  id           text primary key,          -- evt_…
  type         text not null,
  processed_at timestamptz not null default now()
);

-- And one fulfilment per Checkout Session, even if two events point at it.
alter table purchases add constraint purchases_session_unique unique (checkout_session_id);

Stripe also notes that in some cases two separate Event objects are generated for the same thing. That’s why fulfilment gets its own unique key on the object, here the Checkout Session ID, not just the event ID.

2. Fulfil from the API, in one transaction

Dolib/stripe-webhook.ts
// lib/stripe-webhook.ts  (postgres.js shown; any database with a unique key works)
import Stripe from 'stripe';
import sql from './db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function handle(event: Stripe.Event) {
  // Only the events you subscribed to. Ignore the rest.
  if (event.type !== 'checkout.session.completed' &&
      event.type !== 'checkout.session.async_payment_succeeded') return;

  // Re-fetch from Stripe: the API is the source of truth, not the payload.
  const session = await stripe.checkout.sessions.retrieve(
    event.data.object.id, { expand: ['line_items'] },
  );
  if (session.payment_status === 'unpaid') return; // delayed method: wait for async_payment_succeeded

  // Who paid? The user ID YOU put on the session when you created it, server-side.
  const userId = session.client_reference_id;
  if (!userId) throw new Error(`No client_reference_id on ${session.id}`);

  await sql.begin(async (tx) => {
    // Seen this event before? Then do nothing.
    const fresh = await tx`
      insert into stripe_events (id, type) values (${event.id}, ${event.type})
      on conflict (id) do nothing returning id`;
    if (fresh.length === 0) return;

    // Same transaction: the event is marked done only if the upgrade happens.
    await tx`
      insert into purchases (checkout_session_id, user_id)
      values (${session.id}, ${userId})
      on conflict (checkout_session_id) do nothing`;
    await tx`update users set plan = 'pro' where id = ${userId}`;
  });
}

If the upgrade fails, the transaction rolls back, the event isn’t marked as done, your handler returns an error, and Stripe’s retry gets another chance.

Three details carry the security here:

  • Re-fetch the object. Stripe’s fulfilment guide retrieves the Checkout Session from the API (with line_items expanded) and checks payment_status before fulfilling. It’s also a second, independent proof the session is real.
  • Check payment_status. unpaid means don’t fulfil yet. Delayed methods like bank debits send checkout.session.async_payment_succeeded later.
  • Identify the user from your own data. Set client_reference_id (or metadata) when your server creates the session, from your auth session. Don’t look users up by an email someone typed.
app/api/checkout/route.ts
// When you create the Checkout Session (server-side), attach YOUR user ID.
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: process.env.PRO_PRICE_ID!, quantity: 1 }],
  client_reference_id: user.id,              // from your auth session, not from the request body
  success_url: 'https://your-app.com/welcome?session_id={CHECKOUT_SESSION_ID}',
});

3. Listen only to the events you need

EventWhen it arrivesWhat to do
checkout.session.completedCustomer finished CheckoutRetrieve the session; fulfil if payment_status isn’t unpaid
checkout.session.async_payment_succeededA delayed payment (e.g. bank debit) cleared laterFulfil now
checkout.session.async_payment_failedThat delayed payment failedDon’t grant access; tell the customer
invoice.paidA subscription invoice was paid (first or renewal)Retrieve the subscription, confirm status is active, extend access
invoice.payment_failedA renewal payment failedNotify the customer; Stripe’s retry settings decide what happens next
customer.subscription.updatedPlan change, renewal, trial end, status changeSync plan and status from the object
customer.subscription.deletedThe subscription endedRemove Pro
charge.refunded / charge.dispute.createdMoney went back or is contestedReview or restrict access

4. Return 2xx fast, do the slow work after

Stripe says your endpoint must quickly return a 2xx before any complex logic that could cause a timeout, and recommends processing events with an asynchronous queue so a burst (like the first of the month, when subscriptions renew) can’t overwhelm you. Verify, record the event, enqueue the heavy work (emails, provisioning, analytics), and respond. With a success_url set, Checkout also waits up to 10 seconds for your response to checkout.session.completed before redirecting the customer, so a slow handler shows up as a slow checkout.

THE BUILDER’S TAKEAWAYVerification proves who sent it. Idempotency and re-fetching prove it should change anything.

08

Test it with the Stripe CLI before you ship

You don’t need a public URL to test signed events. The Stripe CLI forwards real, signed events from your sandbox to localhost:

terminal
# 1. Log in once
stripe login

# 2. Forward events to your local server (keep this running)
stripe listen --forward-to localhost:3000/api/webhook
# > Ready! Your webhook signing secret is whsec_…   ← put THIS in .env.local

# 3. In another terminal, fire a real, signed test event
stripe trigger checkout.session.completed

# 4. Replay a specific event to a registered endpoint (duplicate test)
stripe events resend evt_123 --webhook-endpoint=we_123

stripe trigger creates real sandbox objects to produce the event, so you may see related events (like payment_intent.created) too. Use --events on stripe listen to filter.

  1. Run your app on localhost:3000, then stripe listen --forward-to localhost:3000/api/webhook and copy the whsec_ it prints into your local env.
  2. Run stripe trigger checkout.session.completed. The stripe listen terminal should show the event and your handler’s 200.
  3. Send the forged curl request from the attack section to localhost. You should get 400, and nothing in your database should change.
  4. Trigger the same event twice, or use stripe events resend, and confirm the user is upgraded exactly once.
  5. Deploy, register the production endpoint in Workbench with only the events you need, set that endpoint’s own whsec_ in production, and run the forged curl against production once.

Unit-test the check itself

stripe-node can sign a payload for you with a test secret, so you can prove in CI that good signatures pass and tampered or missing ones fail:

webhook.test.ts
// webhook.test.ts  (sign a fake payload with a test secret, no network needed)
import Stripe from 'stripe';

const stripe = new Stripe('sk_test_dummy');
const secret = 'whsec_test_secret';
const payload = JSON.stringify({ id: 'evt_test_webhook', object: 'event' }, null, 2);

const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
const event = stripe.webhooks.constructEvent(payload, header, secret);       // passes
expect(event.id).toBe('evt_test_webhook');

// Change ONE byte of the body and it must throw:
expect(() => stripe.webhooks.constructEvent(payload + ' ', header, secret)).toThrow();
// And the forged request from the attack section, with no header at all:
expect(() => stripe.webhooks.constructEvent(payload, '', secret)).toThrow();

THE BUILDER’S TAKEAWAYA test that proves a forged event gets a 400 is worth more than a handler that looks right.

09

“Make the error go away” is not a security fix

Voxel illustration: a calm lobby at night where Aster sits at the WEBHOOK desk reviewing code on his open laptop under a glowing lime padlock, the lime lock on his shirt visible, with Claude relaxed beside him, the doorman checking a sealed envelope at the lime scanner behind them, and the golden retriever asleep on a rug without its vest.
Locked. The doorman checks signatures now. The retriever took it well.

In the episode, Claude points out that it wrote the webhook after being asked to “make the error go away”, and it’s very literal. That’s the joke, but the pattern is real: the signature error is loud, the fix looks like plumbing, and deleting one line makes everything work. For everyone.

The three moves are small: verify with constructEvent and the raw body, handle each event ID once, and only grant Pro for verified, paid events you re-fetched. Our other guides cover the same kind of gap elsewhere: Supabase Row Level Security, keeping API keys out of the browser and protecting a paid AI endpoint.

THE BUILDER’S TAKEAWAYTutorials skip it because it’s boring. Attackers love boring.

QUESTIONS PEOPLE ASK

FAQ

What does “No signatures found matching the expected signature for payload” mean?

stripe-node computed the signature for the body and secret you passed and it didn’t match the Stripe-Signature header. Almost always the body was changed before verifying (parsed to JSON and re-stringified, a body-parser middleware, a proxy) or you used the wrong whsec_ secret (CLI vs Dashboard, sandbox vs live, another endpoint’s). Pass the raw body and the secret for that exact endpoint. Don’t remove the check.

Why does my Stripe webhook work locally but fail in production?

Locally you’re probably verifying with the secret printed by stripe listen. A production endpoint registered in Workbench has its own whsec_, and sandbox and live secrets differ too. Also check that production doesn’t add a global JSON parser, proxy or edge runtime that the local setup didn’t have.

How do I get the raw body for Stripe in the Next.js App Router?

Call await req.text() in the route handler and pass that string to stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature'), secret). Don’t call req.json() first, because a body can only be read once. On the edge runtime, use constructEventAsync. In the Pages Router, export config = { api: { bodyParser: false } } and read the raw buffer.

Why does Express break Stripe signature verification?

Because express.json() parses the body before your handler sees it. Register the webhook route first with express.raw({ type: 'application/json' }), and add app.use(express.json()) after it for your other routes.

Isn’t a secret webhook URL enough?

No. A URL isn’t a password: common paths are easy to guess, and URLs leak through repos, logs and screenshots. Stripe requires the endpoint to be publicly reachable and tells you to verify the Stripe-Signature header on every event before acting on it.

Does Stripe send the same webhook event more than once?

It can. Stripe says endpoints might occasionally receive the same event more than once, retries failed deliveries for up to three days in live mode, and doesn’t guarantee order. Store each event.id under a unique key and skip repeats, and make fulfilment unique per object (for example the Checkout Session ID), because Stripe notes that sometimes two separate events are generated.

What is the default timestamp tolerance in constructEvent?

300 seconds (5 minutes) in stripe-node. Events signed longer ago than that throw “Timestamp outside the tolerance zone”, which protects against replayed events. Stripe re-signs every retry with a fresh timestamp. Keep your server clock in sync and don’t set the tolerance to 0, which Stripe says disables the recency check.

Can I grant Pro from the Checkout success page instead of a webhook?

Not on its own. A success URL or ?paid=1 can be opened by anyone, and Stripe says you can’t rely on the landing page because customers don’t always reach it. You can run the same fulfil function from the landing page with the session_id, as long as it re-fetches the session from Stripe, checks payment_status, and is idempotent. Webhooks stay required.

THE BIGGER PICTURE

The takeaway for builders.

An unverified Stripe webhook doesn’t look broken. Payments work, upgrades work, and anyone who sends the right JSON gets the same upgrade for free. Make the doorman check signatures: verify with `constructEvent` on the raw body, handle each event once, and only grant access for paid sessions you fetched from Stripe yourself. Then send yourself a forged event and watch it bounce. A free Aster repository audit can take a second look at the rest of your code.

The source notebook 11 links

Go a little deeper. These are the sources linked in this story.

  1. 01Stripe Docs: receive Stripe events in your webhook endpointdocs.stripe.com
  2. 02Stripe Docs: verify events are sent from Stripedocs.stripe.com
  3. 03Stripe Docs: fulfill orders (Checkout)docs.stripe.com
  4. 04securityscanner.dev (vendor claim): “We probed 6,000 web apps for Stripe webhook signature checks. 1,542 don’t bother.” (May 2026)securityscanner.dev
  5. 05stripe-node README: webhook signinggithub.com
  6. 06Stripe Docs: resolve webhook signature verification errorsdocs.stripe.com
  7. 07stripe-node: webhook-signing examples (Express, Next.js App and Pages Router, Deno)github.com
  8. 08Stripe CLI reference: stripe listendocs.stripe.com
  9. 09Stripe Docs: webhook best practices, handle duplicate eventsdocs.stripe.com
  10. 10Stripe Docs: using webhooks with subscriptionsdocs.stripe.com
  11. 11Stripe CLI reference: stripe triggerdocs.stripe.com

PUT THE CONTEXT TO WORK

A second look.
Before you ship.

Connect your GitHub repository and see what needs attention. Your first audit is free.

Start my free audit

KEEP THE CURIOSITY GOING

Your next read.

YOUR FEED, WITH A LITTLE MORE CONTEXT

See it. Save it.
Build something better.

Catch the short version on your favorite platform.
Come back here for the details and sources.

Aster

Hi, I’m Aster.

I can help with your first audit, plans, or connecting GitHub. What can I help you with?

Prepared site guidance