A cancellation button that doesn't call the Stripe API leaves the subscription active in Stripe — the user is still charged next month. Conversely, a cancellation that updates Stripe but has no webhook handler leaves your database showing active status after Stripe cancels — former subscribers retain premium access indefinitely. OWASP A01 (Broken Access Control) and CWE-284 (Improper Access Control) cover the access retention side. Failing to provide any self-serve cancellation path is also a compliance concern in many jurisdictions that require equal ease of cancellation as signup.
High because a broken cancellation flow either continues charging users after they cancel or grants indefinite premium access after cancellation — both are severe business failures.
Implement cancellation with end-of-period access (most user-friendly), and ensure both the Stripe API call and the corresponding webhook handler are in place.
// app/api/billing/cancel/route.ts
export async function POST(req: Request) {
const user = await getCurrentUser()
await stripe.subscriptions.update(user.stripe_subscription_id, {
cancel_at_period_end: true
})
await db.user.update({
where: { id: user.id },
data: { cancel_at_period_end: true }
})
return Response.json({ message: 'Subscription will cancel at period end' })
}
Then in your webhook handler, process customer.subscription.deleted to set plan: 'free' and subscription_status: 'canceled' when the billing period actually ends. Without the webhook handler, the cancellation UI is cosmetic only.
ID: saas-billing.pricing-enforcement.cancellation-flow-complete
Severity: high
What to look for: Trace the cancellation flow from user intent to access revocation. Look for a cancellation UI (a cancel button in billing settings or Stripe Customer Portal). Check whether cancellation calls stripe.subscriptions.cancel() or stripe.subscriptions.update({ cancel_at_period_end: true }). Verify the webhook handler for customer.subscription.deleted (immediate cancel) and customer.subscription.updated where cancel_at_period_end: true (end-of-period cancel). Confirm the database is updated and access is revoked at the right time.
Pass criteria: Count every cancellation entry point. At least 1 cancellation flow exists (UI or Customer Portal). Cancellation calls the Stripe API to cancel or schedule cancellation. Webhook handlers for customer.subscription.deleted and the cancel_at_period_end case of customer.subscription.updated both correctly update the database and revoke access.
Fail criteria: 0 cancellation entry points exist and Stripe Customer Portal is not configured. Do NOT pass if a cancellation button exists in the UI but does not call the Stripe API. Cancellation UI calls the Stripe API but no webhook handler processes the resulting events. Subscription is marked as canceled in the database immediately but access revocation logic is missing.
Skip (N/A) when: No subscription billing detected.
Detail on fail: "No cancel subscription endpoint or Customer Portal integration — users cannot self-serve cancel" or "Cancel button calls Stripe API but customer.subscription.deleted webhook is not handled — database retains active status"
Remediation: Implement cancellation with end-of-period access (the most user-friendly approach):
// app/api/billing/cancel/route.ts
export async function POST(req: Request) {
const user = await getCurrentUser()
// Cancel at end of current billing period
await stripe.subscriptions.update(user.stripe_subscription_id, {
cancel_at_period_end: true,
})
await db.user.update({
where: { id: user.id },
data: { cancel_at_period_end: true }
})
return Response.json({ message: 'Subscription will cancel at period end' })
}
Then handle customer.subscription.deleted in your webhook to set plan: 'free' when the period actually ends.