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.
Critical because any authenticated user can self-assign paid plan access with a single API call, bypassing payment entirely.
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.
// 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.
ID: saas-billing.payment-security.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:
// 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.