Skip to content
Back to field notes
PaymentsJune 6, 20267 min read

Stripe webhook signature verification: the check AI builders skip

An AI-built Stripe webhook handler that skips signature verification will trust any POST it receives, so this note helps a founder decide whether their endpoint is safe to flip live.

StripeWebhooksPayments

A webhook that trusts any POST grants whoever finds it

Your Stripe webhook endpoint is a public URL that fulfills orders, marks invoices paid, or upgrades a subscription tier when Stripe tells it an event happened. The whole flow hangs on one assumption: that the POST really came from Stripe. Stripe webhook signature verification is the step that confirms it. When an AI-generated handler skips that step, the endpoint will act on any POST that reaches it, from anyone.

That is the concrete launch risk. If the handler reads the JSON body and updates your database without checking the Stripe-Signature header, then a request crafted by hand can claim a checkout.session.completed event for any customer, with any amount. The sender does not need a key, a session, or a real payment. They need the URL, which is discoverable, and a body shaped like a Stripe event, which is documented.

Separate what is observable from what is only possible here. That a webhook route exists and answers requests is observable from outside. Whether the code behind it verifies the signature is not visible from the public surface at all. The only way to know is to read the handler. So this is a check you run on your own code, not something an outsider can confirm by probing your URL.

  • The risk is forged events, not stolen data: a fake event that flips your app into a paid or fulfilled state.
  • It is an authentication gap on the webhook channel. The handler cannot tell a real Stripe call from a forged one because it never authenticates the sender.
  • A clean payment flow in the browser tells you nothing. The forgery path goes straight to the webhook URL and never touches your checkout UI.

Why AI-built handlers skip the check

Stripe verifies a webhook by signing the exact bytes of the request body with your endpoint's signing secret (the value that starts with whsec_) and putting the result in the Stripe-Signature header. Their SDK exposes one function, constructEvent in Node or construct_event in Python, that recomputes the signature over the raw body and throws if it does not match. The catch is that it must run against the raw, unparsed request body. Two failure patterns show up in generated code, and both pass a casual test.

The first is the handler that never calls constructEvent at all. The model produces a route that does JSON.parse on the body, reads event.type, and runs the fulfillment logic. It works perfectly with Stripe's real events, because Stripe sends well-formed JSON. Nothing in the happy path reveals that the signature was never checked.

The second is subtler and more common: the handler calls constructEvent but feeds it the already-parsed body instead of the raw bytes. Most frameworks apply a JSON body parser globally, so by the time the route runs, the original byte stream is gone. The signature is computed over a re-serialized object whose key order or spacing differs from what Stripe signed, so verification fails on real events. The quick fix a model reaches for is to wrap the call in a try/catch that swallows the error and processes the event anyway, which is verification in name only.

  • The signing secret (whsec_) is a server-side value and never appears in the client bundle, so a bundle scan cannot detect any of this.
  • Body-parser middleware is on by default in Express, Next.js API routes, and most Nuxt/Nitro setups, which is exactly what breaks raw-body access.
  • Webhook code rarely gets a real failure test, because the only way to fail it is to send an unsigned or tampered request on purpose.

Check your own handler from the code and a test POST

This is a self-check on your own app. Two angles, both safe to run against your own endpoint.

Read the handler first. Find the route that Stripe calls (search your code for constructEvent, construct_event, or your webhook path) and confirm three things in order: it reads the raw request body, not a parsed JSON object; it passes that raw body plus the Stripe-Signature header and your whsec_ signing secret into constructEvent; and a verification failure stops the request with a 400 rather than falling through to your fulfillment logic. If any of those three is missing, the check is incomplete.

Then prove it with a request. From your terminal, send a POST to your own webhook URL with a plausible JSON body and no valid Stripe-Signature header. You are testing one thing: does the endpoint reject it before doing any work.

A note on the body parser: if your test POST with a real, correctly signed Stripe event also fails verification, that is the raw-body problem, not a Stripe issue. The route needs the unparsed body. In Express this means express.raw for that route; in Next.js it means disabling the default body parser on the webhook route and reading the raw stream; in Nuxt/Nitro it means reading the raw body before any parsing.

  • Suspicious result: the endpoint returns 200, or your logs show it ran fulfillment logic, after a POST with no signature or a wrong signature. That means it is not verifying.
  • Safer result: the endpoint returns 400 (Stripe's own examples use this for a failed signature) and your fulfillment code never runs. The handler refused an unauthenticated event, which is the behavior you want.
  • Watch for the swallowed error: if your code logs a verification failure but still returns 200 and processes the event, that is the try/catch trap. Treat it as failing.

Fix it: verify against the raw body, reject on failure

The fix is to make constructEvent the gate every webhook request passes through, fed the raw body, with a hard rejection when it fails.

In Node with Express, register the raw body parser for the webhook route only (express.raw with type application/json), then call stripe.webhooks.constructEvent with that raw buffer, the Stripe-Signature header, and your signing secret from the environment. Wrap it in try/catch, but on catch return a 400 and stop. Do not run any fulfillment code in the catch branch. Only after constructEvent returns a verified event do you switch on event.type.

In Python with Flask or FastAPI, read request.get_data() (the raw bytes, before any JSON parsing), pass it with the signature header and signing secret to stripe.Webhook.construct_event, and let it raise on a bad signature or payload. Catch the exception, return a 400, and return before touching your business logic.

What this fix covers and what it does not:

  • It covers authentication of the sender: a verified event is one Stripe actually signed with your secret. Forged POSTs are rejected.
  • It mitigates replay attacks, because Stripe's signature includes a timestamp and constructEvent rejects events outside a tolerance window. It does not make your handler idempotent: Stripe can legitimately deliver the same real event more than once, so you still need to dedupe on the event id before acting.
  • It does not validate that the event content matches your records. A verified checkout.session.completed still needs a server-side check that the amount and line items match what you actually charged. Signature verification proves who sent the event, not that the order is correct.
  • It does nothing for the rest of the payment surface. Test-mode keys in production, a secret key in the client bundle, or client-supplied prices are separate launch blockers covered in the Stripe payments pre-launch security note and the test-vs-live key note.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check works from the public surface, so here its role is narrow and worth stating plainly. It can flag a public webhook route that responds to an unauthenticated GET with a 200 or leaks metadata in the response, which is a signal the route is exposed more than it should be. That overlaps with the public webhook endpoint signals note, which covers what a scan can and cannot see at the URL level.

What it cannot see is whether your handler calls constructEvent, whether it uses the raw body, or whether it rejects a bad signature. That logic lives in server code behind the URL and is invisible to any public scan. Confirming it is a code review task, not a scan result.

Next step: open your webhook handler and confirm the three things above, raw body, constructEvent, and a 400 on failure, then send one unsigned test POST to watch it get rejected. After that, run a focused Launch Check before sharing the URL publicly to catch the other payment-surface blockers a scan can actually see.

A clean Launch Check does not prove your webhook verifies signatures. The scan reaches the outside of the endpoint; it cannot read the handler that decides whether to trust an event. The signature check is something you confirm by reading and testing your own code.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.