All 15 checks with why-it-matters prose, severity, and cross-references to related audits.
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.
Why this severity: 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.
saas-billing.payment-security.webhook-signature-verificationSee full patternStripe tells you plainly that an endpoint must verify the `Stripe-Signature` header before it trusts an event, and most AI-generated handlers do call `constructEvent`. The failure this check targets is the quieter one: verification exists in the file but the code path around it fails open. The common shapes are `if (process.env.STRIPE_WEBHOOK_SECRET) { verify }` so an unset secret in a preview or misconfigured production deploy silently turns verification off, a `catch` block that only logs (`console.error`) and then falls through to the handler, and a fallback such as `event = JSON.parse(body)` when `constructEvent` throws. Each one converts "reject the request" into "process it anyway". This is CWE-345 (Insufficient Verification of Data Authenticity) and OWASP A08, and a hand-rolled verifier that compares signatures with `==` or `===` adds the timing-comparison weakness CWE-208. Plain existence of signature verification is covered by the Webhook Signature Verification check in this audit; this check asks the different question of whether the code still rejects when verification does not succeed. A forged event is a free subscription, a free fulfilled order, or a forged refund state, with no trace in Stripe because the payment never happened.
Why this severity: Critical because a verification step that can be skipped, swallowed, or bypassed is functionally absent: anyone who can POST to the endpoint can forge `checkout.session.completed` or `invoice.paid` and receive paid access without paying.
stripe-billing-integrity.webhook-authenticity.stripe-webhook-verification-fails-closedSee full patternStripe signs `timestamp.payload` and the SDK rejects events whose timestamp is outside a tolerance window, 300 seconds by default, so a captured request cannot be replayed hours later. The tolerance is the fourth argument to `constructEvent(payload, header, secret, tolerance)`, and the failure is overriding it to make a local test or a clock-skew error go away: `0`, a negative number, `Infinity`, or a huge literal. Passing `0` is the trap that matters in practice because several SDKs treat a falsy tolerance as "do not check the timestamp", and an assistant that was asked to "fix the timestamp outside tolerance error" will often disable the check rather than fix the server clock. With replay protection off, any signed event an attacker observes (a leaked log line, a proxy capture, a staging copy of production traffic) can be re-sent to re-run fulfillment or entitlement logic. This is CWE-294 (Authentication Bypass by Capture-replay) and CWE-345, and it is independent of whether the signature itself is verified: the signature stays valid forever once the window is disabled. Stripe documents the default tolerance and recommends against disabling timestamp checks.
Why this severity: High because a disabled or enormous timestamp tolerance lets a captured, validly signed event be replayed indefinitely, so one intercepted `invoice.paid` or `checkout.session.completed` can be re-submitted later to re-trigger fulfillment, but it requires the attacker to have captured a real signed payload first.
stripe-billing-integrity.webhook-authenticity.stripe-webhook-tolerance-not-disabledSee full patternWebhook providers (Stripe, Clerk, GitHub, Svix) retry delivery on any 5xx response — which is the correct behavior for eventual consistency. But a webhook handler without idempotency converts that reliability guarantee into a defect multiplier: a transient 500 during `payment.succeeded` processing means the payment is recorded twice, the fulfillment email sends twice, and the inventory decrements twice. For Stripe specifically, this can cause double charges in your records, triggering refund reconciliation issues that require manual intervention. Idempotency is not an optional hardening step — it is the contract that makes retries safe.
Why this severity: Critical because non-idempotent webhook handlers turn provider retry logic into a data corruption vector, causing duplicate charges, duplicate records, and fulfillment errors on every transient 5xx.
ai-slop-cost-bombs.job-hygiene.webhook-handlers-idempotentSee full patternStripe retries failed webhook deliveries up to 3 days after the first failure. If your webhook handler uses plain `db.create()` or `db.insert()` rather than upserts, a retried event will either throw a unique constraint violation (silently dropping the event) or insert a duplicate row (corrupting subscription state). CWE-345 (Insufficient Verification of Data Authenticity) and ISO 25010 functional-correctness both apply. Handlers that perform slow synchronous operations (email sends, third-party API calls) before returning 200 also cause Stripe to time out and trigger additional retries, compounding the problem.
Why this severity: Medium because non-idempotent handlers corrupt subscription state on retry and can leave billing records permanently inconsistent.
saas-billing.subscription-mgmt.webhook-retry-handlingSee full patternStripe's webhook delivery has a 30-second timeout per attempt. If your webhook handler performs multiple sequential database writes, sends email via an external API, updates inventory, and calls third-party fulfillment services before returning 200, the total latency can easily exceed 30 seconds under normal load — and will exceed it under any database contention or external service slowdown. Stripe then marks the delivery as failed and retries, causing your handler to run again on the same event. Without idempotency guards, these retries create duplicate orders, duplicate emails, and duplicate inventory decrements. CWE-400 (Uncontrolled Resource Consumption) captures the timeout-induced retry cascade.
Why this severity: Low because excessive synchronous processing before returning 200 causes Stripe to retry the webhook, and without idempotency guards those retries produce duplicate orders and emails.
ecommerce-payment-security.payment-errors.webhook-quick-responseSee full patternStripe does not guarantee that webhook events arrive in the order they were generated, and retries make reordering routine: a delayed `customer.subscription.updated` can land after the `customer.subscription.deleted` that logically followed it. A handler that writes `status`, `plan`, or period dates straight from `event.data.object` therefore lets a stale event overwrite newer state, so a customer who canceled appears active again, or a paying customer loses access because an older `past_due` update arrived last. The tempting fix, comparing `event.created` timestamps, is also unreliable: Stripe documents that it does not guarantee delivery order and `created` has one-second resolution, so two events in the same second tie. The robust patterns are to treat the event as a notification and re-fetch the object (`stripe.subscriptions.retrieve(id)`) before writing, or to guard the write with a monotonic marker stored alongside the row. This is a data-integrity failure rather than a security hole, but its effect is billing state that disagrees with Stripe, which turns into either free access or wrongly revoked paid access.
Why this severity: Medium because out-of-order overwrites cause wrong subscription state (a canceled customer shown as active, or the reverse) that persists until the next event, but they require specific delivery timing and are usually self-correcting on the next event.
stripe-billing-integrity.event-processing.stripe-handlers-tolerate-out-of-order-eventsSee full patternAI scaffolding routinely generates `/api/checkout` handlers that accept `amount`, `price`, or `priceId` from the client request body and pass the value straight into `stripe.checkout.sessions.create(...)`, `paddle.transactions.create(...)`, `lemonSqueezy.checkouts.create(...)`, `polar.checkouts.create(...)`, or `braintree.transaction.sale(...)`. Any buyer can open browser dev tools, rewrite the outbound request, and pay $0.01 for a $999 product — the handler dutifully forwards the tampered number to the processor, which has no idea the merchant's intended price was different. This pattern is one of the most common indie-SaaS exploits reported on Twitter/X and in Shopify-app vulnerability disclosures since 2019; Stripe's own `Checkout.sessions.create` documentation opens with an explicit warning against it. The fix is trivial (look up the price server-side from a catalog or validated allowlist), but the LLM default — "accept what the client sent, pass it through" — produces the broken shape every time unless the prompt specifies server-side derivation. Once the site has real traffic, automated scrapers find mispriced checkouts within days.
Why this severity: Critical because every successful exploit is a direct revenue loss that compounds per sale, and attackers can script unlimited $0.01 purchases across the entire catalog in minutes.
project-snapshot.abuse.payment-amount-server-side-onlySee full patternAny API route that accepts a client-supplied `plan`, `tier`, or `subscription_status` and writes it directly to the database is a privilege escalation vulnerability. OWASP A01 (Broken Access Control) and CWE-602 (Client-Side Enforcement of Server-Side Security) describe this precisely: the client is never a trusted source of truth about its own entitlements. An attacker can upgrade themselves to any plan tier with a single crafted POST request. PCI DSS Req 8.2.1 requires that access to payment-related data is based on verified identity, not client assertion.
Why this severity: Critical because any authenticated user can self-assign paid plan access with a single API call, bypassing payment entirely.
saas-billing.payment-security.no-payment-bypass-apiSee full patternJWTs and session cookies have fixed expiry windows. If a subscription lapses between token issuances, the user retains premium access until their next login — potentially days or weeks. CWE-613 (Insufficient Session Expiration) and CWE-602 (Client-Side Enforcement of Server-Side Security) both apply. NIST AC-3 (Access Enforcement) requires that access decisions happen at the enforcement point, not at authentication time. Reading subscription status from the database on each request is the only way to ensure that a cancellation or failed payment takes effect immediately.
Why this severity: High because stale JWT claims allow canceled subscribers to retain paid access for the full token lifetime without any server-side re-verification.
saas-billing.subscription-mgmt.subscription-server-verifiedSee full patternIf your webhook handler doesn't process `customer.subscription.updated` events for downgrades and cancellations, plan data in your database goes stale — canceled subscribers retain premium access indefinitely. OWASP A01 (Broken Access Control) and CWE-284 (Improper Access Control) apply, plus ISO 25010 functional correctness: the system doesn't do what it claims. The `cancel_at_period_end` case is the most commonly missed: Stripe sends an `updated` event when a subscription is scheduled to cancel, not a `deleted` event — only the `deleted` event fires when cancellation actually completes.
Why this severity: High because unhandled downgrade and cancellation webhooks leave former paid users with permanent premium access after their subscription ends.
saas-billing.subscription-mgmt.downgrade-removes-accessSee full patternStripe Checkout redirects the customer to your `success_url` after payment, and Stripe's fulfillment guide states that the redirect alone is not a reliable signal: customers can close the tab before it loads, and the URL can be requested by anyone. Fulfillment must be triggered by a verified `checkout.session.completed` (or `invoice.paid`) webhook, or by a server-side `stripe.checkout.sessions.retrieve(session_id)` that confirms `payment_status === 'paid'`. AI-built apps routinely do the opposite: the success page handler reads `?session_id=` and flips `user.plan = 'pro'` without calling Stripe, so visiting `/success?session_id=anything` grants the subscription; or the success page makes a client-side call to an endpoint that trusts the supplied id. This is CWE-602 (Client-Side Enforcement of Server-Side Security) and CWE-284 (Improper Access Control), OWASP A01 and A08. It is distinct from client-supplied plan or status writes, which belong to the No Payment Bypass API check in this audit; this check is about the missing trusted server-side path that confirms a payment actually happened. Outcome: free access to every paid feature for any visitor who learns the success URL pattern.
Why this severity: Critical because paid state written from a return URL or a client-supplied `session_id` can be reached by any user without paying, so the paywall is bypassable by visiting a URL.
stripe-billing-integrity.entitlement-integrity.fulfillment-has-verified-webhook-pathSee full patternA Stripe secret key (`sk_live_*`) exposed in a client-side bundle or a `NEXT_PUBLIC_` environment variable is readable by every visitor who opens DevTools. An attacker can use it to create refunds, list customers, retrieve stored card fingerprints, or cancel subscriptions without touching your server. PCI-DSS 4.0 Req 3.4 prohibits storing or transmitting secret key material outside controlled server environments. OWASP A02 (Cryptographic Failures) flags this as one of the highest-severity credential exposures because the key grants API-level access to your entire Stripe account — charges, payouts, and customer data — to any actor who extracts it from your bundle.
Why this severity: Critical because a leaked secret key grants full Stripe account API access to anyone who reads the client bundle, enabling unauthorized refunds, payouts, and customer data exfiltration.
ecommerce-payment-security.payment-integration.no-secret-keys-clientSee full patternA hardcoded Stripe secret key (`sk_live_*`) in source code becomes a permanent fixture of your git history. Even if you delete the line in a later commit, the key is recoverable by anyone with read access to the repository — including all historical collaborators, any connected CI/CD system, and anyone who ever cloned the repo. CWE-798 (Use of Hard-coded Credentials) and PCI-DSS 4.0 Req 3.6 (protection of cryptographic keys) both treat this as a critical violation. OWASP A07 (Identification and Authentication Failures) flags hardcoded credentials as a root-cause pattern for account takeover. The only safe recovery from a hardcoded secret is immediate key rotation — the exposed key must be treated as permanently compromised.
Why this severity: High because a hardcoded key is permanently embedded in git history, meaning rotation is required the moment it is detected and any delay leaves the Stripe account accessible to repository readers.
ecommerce-payment-security.payment-integration.keys-from-envSee full patternEvery Stripe event carries a `livemode` boolean, and test and live mode use different webhook signing secrets. The failure is an app that serves both modes from one endpoint with one secret variable, typically because the developer pasted the test secret into production, or registered a single endpoint that receives both. Once production accepts test events, any state they create is indistinguishable from a real purchase: a `checkout.session.completed` generated in Stripe's test mode, which costs nothing and can be produced by anyone holding that endpoint's test signing secret (your team, a test card, a local `stripe listen` run, a leaked secret), grants the same plan as a real payment. The same shared setup also means a developer's local test run can mutate production entitlements. The safe designs are either separate endpoints and separately named secrets per environment (`STRIPE_WEBHOOK_SECRET_LIVE`/`_TEST`, or per-deployment values) plus a `livemode` check, or at minimum an explicit guard that rejects events whose `livemode` does not match the environment. Stripe documents `livemode` on the Event object for exactly this purpose. The harm is entitlements and revenue records that do not correspond to money.
Why this severity: High because a test-mode event accepted by the production handler lets test cards, local runs, or anyone holding the test signing secret create "paid" subscriptions that the live app honors, so exposure is mostly your own team and tooling plus any leak of that secret.
stripe-billing-integrity.mode-and-key-hygiene.stripe-test-events-cannot-grant-live-entitlementsSee full patternRun this audit in your AI coding tool (Claude Code, Cursor, Bolt, etc.) and submit results here for scoring and benchmarks.
Open Stripe Webhooks & Billing Integrity