Cancellation flow is complete
Why it matters
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.
Severity rationale
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.
Remediation
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.
Detection
-
ID:
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()orstripe.subscriptions.update({ cancel_at_period_end: true }). Verify the webhook handler forcustomer.subscription.deleted(immediate cancel) andcustomer.subscription.updatedwherecancel_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.deletedand thecancel_at_period_endcase ofcustomer.subscription.updatedboth 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.deletedin your webhook to setplan: 'free'when the period actually ends.
External references
- cwe · CWE-284 — Improper Access Control
- owasp:2021 · A01 — Broken Access Control
Taxons
History
- 2026-04-18·v1.0.0·Initial import from saas-billing·automated