# No way to bypass payment via API

- **Pattern:** `ab-002265` (`saas-billing.payment-security.no-payment-bypass-api`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002265
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002265)

## Why it matters

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

## Severity rationale

Critical because any authenticated user can self-assign paid plan access with a single API call, bypassing payment entirely.

## Remediation

Subscription state must only flow from verified webhook events — never from client-supplied fields. Your upgrade route should create a Checkout Session or Payment Intent and return a redirect URL; the plan change happens only after Stripe sends a verified webhook.

```ts
// WRONG — never do this
// app/api/user/upgrade/route.ts
await db.user.update({ data: { plan: body.plan } }) // trusts the client

// RIGHT — in your verified webhook handler only:
case 'customer.subscription.updated': {
  const subscription = event.data.object
  await db.user.update({
    where: { stripe_customer_id: subscription.customer as string },
    data: {
      plan: subscription.items.data[0]?.price.lookup_key ?? 'free',
      subscription_status: subscription.status
    }
  })
}
```

Your upgrade API endpoint should only call `stripe.checkout.sessions.create()` and return the session URL — never touch the user's plan field directly.

## Detection

- **ID:** `no-payment-bypass-api`
- **Severity:** `critical`
- **What to look for:** Look for API routes or server actions that upgrade a user's subscription tier, grant premium feature access, or activate a paid plan. Verify that these routes require proof of payment (a valid Stripe Payment Intent ID, a confirmed subscription ID retrieved from Stripe's API, or a webhook event) rather than trusting client-supplied plan names or subscription statuses. Look for routes that accept a `plan`, `tier`, `subscription_status`, or `is_premium` field in the request body. Quote the actual route path and field name for any bypass found and write it directly to the database without server-side verification against the payment provider.
- **Pass criteria:** Enumerate all API routes that modify subscription or plan fields — no more than 0 should allow a client to directly set subscription status or plan tier without server-side verification of a corresponding payment record from the provider's API. Subscription upgrades must be triggered by verified webhook events or by server-side confirmation of a successful payment intent.
- **Fail criteria:** An API route accepts a client-supplied `plan` or `subscription_status` and writes it directly to the database. A route exists that grants premium access without verifying a payment on the server side.
- **Skip (N/A) when:** No subscription tiers or premium features detected — the project has no paid plans.
- **Detail on fail:** `"POST /api/user/upgrade accepts 'plan: premium' in request body and updates user plan without verifying payment with Stripe"` or `"Server action upgradePlan() trusts client-supplied subscriptionStatus parameter"`
- **Remediation:** Never trust the client about its own subscription status. The flow must be: client initiates → Stripe confirms → your webhook receives a verified event → your server updates the database:

  ```ts
  // In your Stripe webhook handler (already signature-verified):
  case 'customer.subscription.updated': {
    const subscription = event.data.object
    await db.user.update({
      where: { stripe_customer_id: subscription.customer as string },
      data: {
        plan: subscription.items.data[0]?.price.lookup_key ?? 'free',
        subscription_status: subscription.status
      }
    })
    break
  }
  ```

  Your upgrade API route should only create a Stripe Checkout Session or Payment Intent and return the client secret — never set the user's plan directly.

---

## External references

- cwe CWE-284
- owasp A01
- cwe CWE-602
- pci-dss Req 8.2.1

Taxons: access-control, data-integrity

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