# Downgrade removes access to premium features

- **Pattern:** `ab-002254` (`saas-billing.subscription-mgmt.downgrade-removes-access`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002254
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002254)

## Why it matters

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.

## Severity rationale

High because unhandled downgrade and cancellation webhooks leave former paid users with permanent premium access after their subscription ends.

## Remediation

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.

```ts
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`.

## Detection

- **ID:** `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:

  ```ts
  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
  }
  ```

## External references

- cwe CWE-284
- owasp A01
- iso-25010 functional-correctness

Taxons: access-control, data-integrity

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