SecureStartKit verifies every Stripe webhook before acting on it.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Call stripe.webhooks.constructEvent with the raw request body from await req.text(), the Stripe-Signature header, and STRIPE_WEBHOOK_SECRET. Wrap it in a try/catch and return a 400 if it throws. Never parse the body with req.json() before verifying.
Where it shows up: The webhook route handler calls req.json() and acts on event.type directly, without calling stripe.webhooks.constructEvent against the raw body and the signing secret in STRIPE_WEBHOOK_SECRET.
// app/api/webhooks/stripe/route.ts (VULNERABLE)
export async function POST(req: Request) {
const event = await req.json() // parsed body, no verification
if (event.type === 'checkout.session.completed') {
const session = event.data.object
await grantAccess(session.metadata.userId, session.metadata.planId)
}
return new Response('ok', { status: 200 })
}The handler reads the JSON body and immediately acts on event.type. Any HTTP client can POST a forged checkout.session.completed payload and trigger fulfillment.
// app/api/webhooks/stripe/route.ts (SECURE)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const rawBody = await req.text() // exact bytes Stripe signed
const sig = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return new Response('Bad signature', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
await grantAccess(session.metadata!.userId, session.metadata!.planId)
}
return new Response('ok', { status: 200 })
}req.text() preserves the original byte sequence. constructEvent recomputes the HMAC and throws if it does not match. A 400 on failure tells Stripe to retry while blocking any forged request.
// app/api/webhooks/stripe/route.ts (VULNERABLE, subtle)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const parsed = await req.json() // body already consumed and parsed
const sig = req.headers.get('stripe-signature') ?? ''
try {
// re-serializing changes whitespace/order: the HMAC never matches
stripe.webhooks.constructEvent(
JSON.stringify(parsed),
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
// developer silently swallows the error and continues anyway
}
if (parsed.type === 'checkout.session.completed') {
await grantAccess(parsed.data.object.metadata.userId, parsed.data.object.metadata.planId)
}
return new Response('ok', { status: 200 })
}JSON.stringify of a parsed object does not reproduce the original byte sequence. The HMAC mismatch is silently swallowed, so every request (forged or real) reaches the fulfillment code.
// app/api/webhooks/stripe/route.ts (SECURE, with idempotency)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const rawBody = await req.text()
const sig = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Bad signature', { status: 400 })
}
// Idempotency: Stripe guarantees at-least-once delivery.
if (await alreadyProcessed(event.id)) {
return new Response('Already processed', { status: 200 })
}
await markProcessed(event.id)
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
await grantAccess(session.metadata!.userId, session.metadata!.planId)
}
return new Response('ok', { status: 200 })
}Always pass req.text() to constructEvent. The idempotency guard (a database column keyed by event.id) is defense in depth: Stripe may deliver the same event more than once, so duplicate events are expected.
An attacker who knows your webhook URL (discoverable from GitHub leaks, JS bundles, or brute force) can send an arbitrary POST request to that URL with a hand-crafted JSON body. They set the event type to checkout.session.completed and embed their own user id and a paid plan id in the session metadata, with a Content-Type of application/json and no valid Stripe-Signature header.
Without signature verification the handler reads event.type, sees checkout.session.completed, and runs the fulfillment logic: updating the database row, granting repo access, sending the delivery email. The attacker receives a paid product at zero cost.
The same technique works for payment_intent.succeeded, customer.subscription.updated, or any other high-value event your handler acts on. No Stripe account is needed; any HTTP client can send the request.
A subtler variant bypasses partial verification: the developer calls constructEvent but passes a re-serialized body (JSON.stringify of the parsed JSON) instead of the original raw bytes. Stripe signs the exact byte sequence it transmitted, so re-serializing changes whitespace and key order and the signature never matches.
Search your webhook route file for calls to req.json() that appear before or instead of stripe.webhooks.constructEvent:
grep -n "req.json" app/api/webhooks/stripe/route.ts
Any hit in a Stripe webhook handler is a red flag. Then confirm constructEvent is actually called and its return value is used:
grep -n "constructEvent" app/api/webhooks/stripe/route.ts
If constructEvent is absent, or sits inside a try/catch whose error branch continues execution instead of returning a 400, the endpoint is unprotected.
Also confirm STRIPE_WEBHOOK_SECRET is set. An undefined secret makes constructEvent throw on every request, which developers sometimes work around by removing the call entirely.
Use the Stripe CLI to replay real events against your local handler with stripe listen, and confirm the handler returns 200 for genuine events and 400 for tampered payloads.
Myth“Parsing with req.json() and then passing JSON.stringify back to constructEvent is equivalent to using req.text().”
JSON.stringify of a parsed object does not reproduce the original byte sequence. Key order, numeric precision, and whitespace can all differ. The HMAC will not match and constructEvent will throw on every legitimate Stripe event.
Myth“Swallowing the constructEvent exception and continuing is safe because it only affects edge cases.”
A swallowed exception means both forged and legitimate events skip verification. An attacker does not need to bypass the HMAC; they only need to trigger the catch block, which any unsigned POST achieves.
Myth“The webhook URL is secret, so there is no real risk without the signing secret.”
Webhook URLs appear in server logs, bundle analysis, git history, and error messages. Obscurity is not a security control. The signing secret is the only cryptographic guarantee.
Myth“Returning a 200 for failed verifications is polite because it stops Stripe from retrying.”
Stripe retries events that receive a non-2xx response, which is correct. A 400 on verification failure tells Stripe the delivery failed without committing to an unverified action. A 200 on every request silently accepts forged events.
The kit Stripe webhook route reads the raw body with req.text(), passes it with the Stripe-Signature header to stripe.webhooks.constructEvent, and returns a 400 if verification fails. No fulfillment logic runs until the signature is confirmed valid. The handler also includes an idempotency guard for duplicate delivery.
How the kit verifies Stripe webhooks