If 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.
High because unhandled downgrade and cancellation webhooks leave former paid users with permanent premium access after their subscription ends.
Handle both customer.subscription.updated and customer.subscription.deleted in your webhook handler, and update both the plan field and subscription status on every event.
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object
const status = subscription.status
const planKey = subscription.items.data[0]?.price.lookup_key ?? 'free'
await db.user.update({
where: { stripe_customer_id: subscription.customer as string },
data: {
plan: ['active', 'trialing'].includes(status) ? planKey : 'free',
subscription_status: status,
subscription_ends_at: new Date(subscription.current_period_end * 1000)
}
})
break
}
Also handle the cancel_at_period_end: true case in customer.subscription.updated — Stripe sends this event when a customer schedules a cancellation but access should continue until current_period_end.
ID: saas-billing.subscription-mgmt.downgrade-removes-access
Severity: high
What to look for: Trace the downgrade flow — what happens when a customer moves from a higher tier to a lower tier, either by canceling a plan in Stripe's customer portal or through a UI downgrade action in your application. Look for webhook handlers for customer.subscription.updated events and check whether the handler updates the user's plan in the database. Look for "access until end of period" logic — this is acceptable (prorated access), but verify that access actually ends at period end, not persists indefinitely. Check whether the downgrade is applied immediately or at renewal, and whether the code correctly handles both cases.
Pass criteria: Count every webhook event type handled — at least 2 event types (updated + deleted) must be covered. When a subscription is downgraded or canceled, access to higher-tier features is revoked either immediately or at the end of the billing period (both are acceptable), AND a webhook handler correctly processes customer.subscription.updated and customer.subscription.deleted events to update the database. The access revocation is enforced server-side.
Fail criteria: Subscription cancellation does not update the database — users retain premium access indefinitely after canceling. Do not pass if only customer.subscription.deleted is handled but cancel_at_period_end scenario of customer.subscription.updated is missed. The webhook handler does not process downgrade events. Plan tier in the database is not updated when subscription changes.
Skip (N/A) when: No subscription tiers detected.
Detail on fail: "No handler for customer.subscription.updated event — plan tier in database is never updated on downgrade" or "customer.subscription.deleted handler sets status to 'canceled' but does not update plan field — premium features remain accessible"
Remediation: Handle both subscription update and deletion webhooks:
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object
const status = subscription.status // active, past_due, canceled, etc.
const planKey = subscription.items.data[0]?.price.lookup_key ?? 'free'
await db.user.update({
where: { stripe_customer_id: subscription.customer as string },
data: {
plan: status === 'active' || status === 'trialing' ? planKey : 'free',
subscription_status: status,
subscription_ends_at: new Date(subscription.current_period_end * 1000)
}
})
break
}