SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Unverified Stripe Webhook Signature
CWE-345High severityStripeNext.js

Unverified Stripe Webhook Signature

✓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.

The vulnerable patterns and their fixes

Handler trusts req.json() with no signature check

✗Vulnerablets
// 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.

↓the fix
✓Securets
// 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.

Verification on a re-serialized body always fails, handler falls through

✗Vulnerablets
// 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.

↓the fix
✓Securets
// 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.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

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.

How to find it in your code

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.

Common mistakes

  • 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.

Does SecureStartKit prevent this?

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→

Frequently asked questions

Why must I use req.text() instead of req.json() in the App Router?
Stripe signs the exact bytes it transmitted. req.json() parses those bytes into an object, discarding whitespace, key order, and numeric precision. Passing anything other than the original byte string to constructEvent causes an HMAC mismatch on every legitimate event.
What does constructEvent actually verify?
It recomputes an HMAC-SHA256 over the raw request body using your STRIPE_WEBHOOK_SECRET and compares it against the signature in the Stripe-Signature header. It also checks the timestamp in that header to reject replayed events older than the tolerance window.
Do I need to disable body parsing for the webhook route?
In the App Router (app directory) the request body is not automatically parsed, so no special configuration is needed. Just call req.text() before anything else. The old Pages Router required disabling the body parser; that is not needed in the App Router.
Is the idempotency guard required for security?
Not for security, since forged events are blocked by signature verification. It prevents double-fulfillment on legitimate retries: Stripe guarantees at-least-once delivery, so without a guard a transient error could grant access or send a delivery email twice.

References

  • CWE-345: Insufficient Verification of Data Authenticity ↗
  • Stripe Docs: Webhook Signature Verification ↗

Related weaknesses

  • Unvalidated Server Action InputA Server Action reads FormData fields or typed arguments and passes them directly to a database query, or spreads them with the spread operator, without first running them through a Zod schema.
  • No Rate Limit on Server ActionsA sensitive or expensive Server Action (password reset, magic-link send, AI call, Stripe Checkout creation) runs with no per-IP or per-user rate limit in front of it.
  • Missing or Disabled RLS PolicyA table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.

Defined terms

  • Stripe Webhook Signature Verification
  • Idempotency

Go deeper

  • Stripe Webhook Signature Verification in Next.js
  • Stripe Webhook Verifier

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns