An unverified webhook endpoint is an open door for attackers to forge billing events — triggering subscription upgrades, skipping payments, or issuing refunds with no real money involved. CWE-345 (Insufficient Verification of Data Authenticity) is the exact failure; OWASP A08 (Software and Data Integrity Failures) and NIST SP 800-53 SI-7 (Software, Firmware, and Information Integrity) both call this out. Stripe's constructEvent() verifies an HMAC-SHA256 signature over the raw request body — if you parse the body to JSON first, the signature will never match, leaving you either always-failing or always-skipping the check.
Critical because a missing or broken signature check lets any attacker forge payment confirmation events, enabling free access to paid features without a real payment.
Read the raw request body before any JSON parsing, then verify the provider's HMAC signature before processing any event. In Next.js App Router, add export const dynamic = 'force-dynamic' to prevent static optimization from breaking body access.
// app/api/webhooks/stripe/route.ts
export const dynamic = 'force-dynamic'
export async function POST(req: Request) {
const body = await req.text() // raw body — must come before any JSON parse
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) {
return new Response('Invalid signature', { status: 400 })
}
switch (event.type) { /* handle events */ }
}
Set STRIPE_WEBHOOK_SECRET from stripe listen --print-secret (dev) or the Stripe Dashboard (prod).
ID: saas-billing.payment-security.webhook-signature-verification
Severity: critical
What to look for: Locate webhook handler files for your payment provider. For Stripe, look for stripe.webhooks.constructEvent() in the webhook handler. For Paddle, look for signature verification using the Paddle SDK or manual HMAC comparison. For LemonSqueezy, look for crypto.timingSafeEqual() or the LemonSqueezy SDK's webhook verification. The handler must read the raw request body (not parsed JSON) and the provider's signature header before verifying. Check that the webhook endpoint does not skip verification based on environment conditions.
Pass criteria: Count every webhook handler endpoint. Every webhook handler for the payment provider calls the provider's official signature verification method with the raw request body and the signing secret before processing any webhook event. The raw body must be captured before any JSON parsing (e.g., using req.text() not req.json() in Next.js Route Handlers, or a rawBody middleware in Express). For Next.js App Router: the webhook route handler must export export const dynamic = 'force-dynamic' (or equivalent runtime configuration). Without this, Next.js may attempt to statically optimize the route, which would break signature verification by not reading the raw request body correctly. If the project uses Next.js App Router and the webhook handler lacks this export, note it in the detail as a secondary finding (not a standalone fail, but a required element of a correct implementation).
Fail criteria: No signature verification found in at least 1 webhook handler. Verification is present but uses parsed JSON body instead of raw body (breaks HMAC). Verification is conditionally skipped in production-like environments. Do not pass if verification is wrapped in a try/catch that swallows the error and continues processing.
Cross-reference: For webhook endpoint security beyond signature verification, the API Security audit covers request validation and rate limiting.
Skip (N/A) when: No webhook endpoint detected. Signal: no route file matching webhook, webhooks, or provider-specific patterns (stripe, paddle, lemon) in API route directories.
Detail on fail: "Webhook handler at app/api/webhooks/stripe/route.ts processes events without calling stripe.webhooks.constructEvent()" or "Webhook handler parses body with req.json() before verification — HMAC will always fail or is not being checked"
Remediation: Unverified webhooks allow anyone to send fake payment events to your application — triggering subscription upgrades, cancellations, or refunds without real payments. Always verify:
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text() // raw body BEFORE parsing
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) {
return new Response('Invalid signature', { status: 400 })
}
// Only process event after successful verification
switch (event.type) { ... }
}
In Next.js App Router, add export const dynamic = 'force-dynamic' and ensure no body parser middleware runs before this handler.