# Webhook endpoints validate payloads

- **Pattern:** `ab-002193` (`saas-api-design.api-security.webhook-validates-payloads`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002193
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002193)

## Why it matters

A webhook endpoint that processes events without signature verification accepts requests from anyone — any attacker who knows your webhook URL can POST a fabricated `payment_succeeded` or `subscription_canceled` event and trigger the corresponding business logic (CWE-345: insufficient verification of data authenticity, OWASP A08:2021). Webhook URLs are not secret: they appear in Stripe dashboards, server logs, and network traces. The signature secret is the only real credential protecting these endpoints.

## Severity rationale

Medium because exploitation requires knowing the webhook URL, which is less trivially discoverable than a public endpoint, but the impact of a forged event — fraudulent order fulfillment, account privilege escalation — is severe.

## Remediation

Verify the webhook signature as the very first operation in your handler, before parsing or acting on the payload. For Stripe in Next.js App Router, disable automatic body parsing and read raw bytes:

```ts
// app/api/webhooks/stripe/route.ts
export const dynamic = 'force-dynamic'

export async function POST(req: Request) {
  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 {
    return new Response('Invalid signature', { status: 400 })
  }

  // Only reaches here if signature verified
  switch (event.type) { ... }
}
```

For other providers, use their official SDK verification method — never hand-roll HMAC comparison.

## Detection

- **ID:** `webhook-validates-payloads`
- **Severity:** `medium`
- **What to look for:** Enumerate all webhook receiver endpoints (routes that receive callbacks from Stripe, GitHub, Clerk, Twilio, SendGrid, or other external services). For each webhook endpoint, check: (1) Is the webhook signature verified before processing? (2) Is raw body available for signature verification (JSON parsing before signature check breaks it)? (3) Is the verification step the very first thing in the handler, before any business logic runs?
- **Pass criteria:** At least 100% of webhook endpoints verify the signature of incoming payloads using the service's SDK or documented verification method (e.g., `stripe.webhooks.constructEvent(rawBody, sig, secret)`, `svix.verify()`, etc.) before executing any business logic.
- **Fail criteria:** Any webhook endpoint that processes the payload before or without signature verification; or that verifies an already-parsed JSON body (which breaks HMAC verification since the raw bytes matter).
- **Skip (N/A) when:** No webhook receiver endpoints exist. Signal: no Stripe `webhooks` handling, no webhook route files, no references to webhook signature headers in any route handler.
- **Detail on fail:** Example: `POST /api/webhooks/stripe processes events without signature verification`. Name the unverified webhook endpoints (e.g., "POST /api/webhooks/stripe processes events without calling stripe.webhooks.constructEvent(); raw body not preserved"). Max 500 chars.
- **Remediation:** Always verify webhook signatures in `app/api/webhooks/` route handlers before trusting incoming payload data. For Stripe: disable Next.js body parsing for the webhook route (`export const config = { api: { bodyParser: false } }`), read the raw body, then call `stripe.webhooks.constructEvent(rawBody, request.headers.get('stripe-signature'), process.env.STRIPE_WEBHOOK_SECRET)`. If this throws, return a 400 immediately without processing. For other services, use their official SDK verification methods. Never process a webhook payload based solely on the URL it arrived at — any attacker can POST to your webhook URL.

## External references

- cwe CWE-345
- cwe CWE-20
- owasp A08

Taxons: access-control, injection-and-input-trust

HTML version: https://auditbuffet.com/patterns/ab-002193
