# User data has a bounded retention period enforced by a purge mechanism or a committed retention schedule

- **Pattern:** `ab-002639` (`soc2-readiness.data-lifecycle.data-retention-or-purge-defined`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-07-03
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002639
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002639)

## Why it matters

SOC 2 C1.2 requires that confidential information be disposed of when it is no longer needed, and "show me your retention schedule and the mechanism that enforces it" is a standard confidentiality-section request; this check evidences C1.2 directly (ISO 27001 A.8.10 and NIST SI-12 map the same obligation). AI-generated and vibe-coded apps fail this almost universally: coding assistants scaffold tables that only ever grow (users, sessions, events, messages, request logs) because every training example inserts and none deletes, so the default posture is indefinite retention of everything, which inflates breach blast radius, storage cost, and GDPR storage-limitation exposure with every day the app runs. One passing leg of this check is an evidence-pointer: where retention is enforced by a managed platform or an operations habit rather than repo-visible code, a committed retention schedule document naming data classes and periods is the evidence, which is exactly the artifact a SOC 2 auditor will ask for. This check is deliberately distinct from account-deletion-coded in this same audit: that one is user-initiated erasure on request (GDPR Art. 17); this one is systematic retention bounds for data nobody asked to delete.

## Severity rationale

Medium because unbounded retention does not itself expose data, but it silently compounds breach blast radius and regulatory exposure over time and leaves the C1.2 disposal criterion with zero evidence until a purge mechanism or schedule exists.

## Remediation

Pick the cheapest leg that fits your stack. Code leg: add a scheduled purge, for example a `vercel.json` crons entry pointing at `app/api/cron/purge-expired/route.ts` that runs `DELETE FROM sessions WHERE created_at < now() - interval '90 days'` (or the Prisma/Drizzle equivalent), or a `cron.schedule(...)` call in a pg_cron migration under `supabase/migrations/`. Storage leg: a Mongo TTL index (`expireAfterSeconds`), a DynamoDB TTL attribute in your IaC, or Redis `EXPIRE` on user-data keys. Evidence leg: commit `docs/ops/data-retention.md` listing each stored data class (accounts, sessions, analytics events, logs, backups) with its retention period and how disposal happens, including "retained indefinitely" entries where that is a deliberate choice. Platform-enforced retention documented in that file is acceptable auditor evidence; undocumented defaults are not.

## Detection

- **ID:** `data-retention-or-purge-defined`
- **Severity:** `medium`
- **What to look for:** First determine whether the app persists user data: a database dependency in `package.json` (`@supabase/supabase-js`, `prisma`, `drizzle-orm`, `mongoose`, `mongodb`, `pg`, `mysql2`, `@aws-sdk/client-dynamodb`, `redis`, `ioredis`), OR schema/migrations (`prisma/schema.prisma`, `supabase/migrations/`, `drizzle/`, `migrations/`), OR for Python/Go: `sqlalchemy`/`django`/`pymongo` in `requirements.txt`/`pyproject.toml`, `gorm`/`database/sql`/`sqlx` in `go.mod`. If none, skip. If user data persists, enumerate the three passing legs: (a) **scheduled purge**: a cron handler under `app/api/cron/*` or `pages/api/cron/*` referenced by a `crons` entry in `vercel.json`, a Supabase scheduled Edge Function (`supabase/functions/*` with a schedule in `supabase/config.toml` or a `cron.schedule(...)` migration), a pg_cron `cron.schedule` statement in `supabase/migrations/*.sql`, or a GitHub Actions `schedule:` workflow, where the job body deletes or expires aged rows (`DELETE ... WHERE ... < now() - interval`, `deleteMany({ createdAt: { lt: ... } })`, or equivalent); (b) **database-level TTL**: a Mongo TTL index (`expireAfterSeconds` in schema or migration), a DynamoDB TTL attribute (`TimeToLiveSpecification` in IaC or `time_to_live` in Terraform), or Redis `EXPIRE`/`SETEX`/`set(..., { EX: ... })` applied to user-data keys (not just cache/rate-limit keys); (c) **committed retention schedule doc**: a file such as `docs/ops/data-retention.md`, `docs/data-retention.md`, `RETENTION.md`, or a retention section in a committed security/privacy policy doc that names at least one stored data class WITH a concrete retention period (explicit indefinite-by-choice statements are acceptable for additional classes, but at least one class must carry a bounded period).
- **Pass criteria:** User data persists and at least one leg is fully wired: (a) the cron schedule entry AND the referenced handler both exist and the handler's body performs the aged-row delete/expire; or (b) the TTL configuration exists on a table/collection/key space that holds user data; or (c) the committed doc names data classes with concrete periods (at least one bounded period; deliberate indefinite-by-choice entries may accompany it). Any one leg suffices.
- **Fail criteria:** Schema or migrations show accumulating user-data tables (users, sessions, events, messages, logs, analytics) and none of the three legs is present. Also fail on the half-wired variants: a `vercel.json` crons entry whose target route does not exist (or vice versa: a purge handler no schedule ever invokes); a purge job that is commented out or gated behind an env flag that no committed config enables; `node-cron` or `bullmq` installed with no purge job defined (dependency presence is not a mechanism); a retention doc whose periods are all `TBD`, that names no data classes, or that declares every class indefinite (no bounded period anywhere is not a retention schedule); Redis `EXPIRE` used only on cache or rate-limit keys while the primary user-data store has no bound. A user-initiated account-deletion endpoint alone does NOT pass this check: that is on-request erasure, not systematic retention.
- **Skip (N/A) when:** No persistent user data: no database dependency, no schema, no migrations, and no user-data writes anywhere. Quote: `"No database dependency, schema, or migrations — project persists no user data"`.
- **Before evaluating, quote:** The evidence for whichever leg passes: the `vercel.json` crons entry (or `cron.schedule` line) plus a 1-3 line excerpt of the handler's delete/expire statement; the TTL index/attribute definition; or the doc path plus one data-class line with its period. On fail, quote the schema/migration lines proving accumulating user-data tables exist.
- **Report even on pass:** `"Retention bound via <purge-cron at <path> | TTL on <table/collection> | retention doc at <path>>; covers <data classes>"`.
- **Detail on fail:** `"prisma/schema.prisma defines users, sessions, and events tables; no cron route, no vercel.json crons entry, no TTL config, no retention doc found"` or `"app/api/cron/purge-sessions/route.ts exists but vercel.json has no crons entry referencing it — purge job is never scheduled; commit the schedule or a docs/ops/data-retention.md as evidence"`.
- **Remediation:** Add a scheduled purge (a `vercel.json` crons entry pointing at `app/api/cron/purge-expired/route.ts` that deletes rows older than your retention window, or a pg_cron `cron.schedule` migration under `supabase/migrations/`), a database-level TTL (`expireAfterSeconds`, DynamoDB TTL, Redis `EXPIRE`), or commit `docs/ops/data-retention.md` naming each data class and its period. For user-initiated erasure (a related but distinct obligation), see the `gdpr-readiness` audit.

## External references

- soc2 C1.2
- iso-27001 A.8.10
- nist SI-12

Taxons: privacy-consent, regulatory-conformance

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