# Trial period enforces feature limits

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

## Why it matters

A trial period stored in a cookie, localStorage, or a client-resettable field is effectively no trial at all — users can restart it indefinitely by clearing storage or creating new accounts. CWE-284 (Improper Access Control) and CWE-602 (Client-Side Enforcement of Server-Side Security) apply. OWASP A01 (Broken Access Control) lists this under privilege escalation. The trial must be fully managed by the payment provider: Stripe tracks `trial_end`, emits `customer.subscription.trial_will_end` three days before expiry, and transitions the status automatically.

## Severity rationale

High because client-manipulable trial state allows indefinite free access to paid features with no payment ever required.

## Remediation

Let Stripe own the trial lifecycle. Store only the `subscription.status` from Stripe webhooks — when it transitions from `trialing` to `active` or `past_due`, your webhook handler updates the database accordingly.

```ts
// When creating a subscription with trial via Stripe:
await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: priceId }],
  trial_period_days: 14,
})
// Stripe emits customer.subscription.trial_will_end 3 days before expiry
// and customer.subscription.updated when trial ends (status → active or past_due)
```

In your webhook handler, treat `trialing` the same as `active` for access decisions, and revoke access when `subscription.status` moves to any non-active state. Never read trial state from the client.

## Detection

- **ID:** `trial-enforces-limits`
- **Severity:** `high`
- **What to look for:** Determine whether the application has a trial period (look for `trial`, `trial_end`, `trialing` in schema files, subscription status checks, or Stripe trial period configuration). If a trial exists, examine whether it enforces the same limits as the paid tier it represents, or whether it provides unlimited access during the trial. Also check for trial extension vulnerabilities: can a user create a new account to restart the trial? Is trial status stored only in the JWT?
- **Pass criteria:** Count every trial-related code path — at least 1 server-side expiry check must exist. Trial users have access to paid features for the trial duration, AND the trial period is enforced server-side (cannot be extended by client manipulation). When the trial ends, access is revoked the same way a subscription cancellation would be. If no trial exists, this check passes.
- **Fail criteria:** Trial status is stored in a cookie or localStorage and can be manipulated. Trial period can be restarted by creating a new account with no friction. Trial end is not enforced — access continues indefinitely after trial expiry.
- **Skip (N/A) when:** No trial period detected in the application. Signal: no `trial`, `trial_end`, or `trialing` references in schema or subscription handling code, and no Stripe trial period configuration.
- **Detail on fail:** `"Trial end date stored in localStorage and read client-side — can be manipulated"` or `"Stripe subscription shows trialing status but application grants full paid access after trial_end without re-verification"`
- **Remediation:** Treat trial status the same way you treat paid subscription status — managed entirely by your payment provider and enforced server-side. Let Stripe manage the trial:

  ```ts
  // When creating a subscription with trial
  await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    trial_period_days: 14,
  })
  // Stripe sends customer.subscription.trial_will_end webhook 3 days before
  // and customer.subscription.updated when trial ends (status changes to active or past_due)
  ```

  Store `subscription.status === 'trialing'` from the webhook in your database. On trial end, Stripe sends another webhook — your handler updates the database to require payment.

## External references

- cwe CWE-284
- owasp A01
- cwe CWE-602

Taxons: access-control, data-integrity

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