User data has a bounded retention period enforced by a purge mechanism or a committed retention schedule
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/pymongoinrequirements.txt/pyproject.toml,gorm/database/sql/sqlxingo.mod. If none, skip. If user data persists, enumerate the three passing legs: (a) scheduled purge: a cron handler underapp/api/cron/*orpages/api/cron/*referenced by acronsentry invercel.json, a Supabase scheduled Edge Function (supabase/functions/*with a schedule insupabase/config.tomlor acron.schedule(...)migration), a pg_croncron.schedulestatement insupabase/migrations/*.sql, or a GitHub Actionsschedule: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 (expireAfterSecondsin schema or migration), a DynamoDB TTL attribute (TimeToLiveSpecificationin IaC ortime_to_livein Terraform), or RedisEXPIRE/SETEX/set(..., { EX: ... })applied to user-data keys (not just cache/rate-limit keys); (c) committed retention schedule doc: a file such asdocs/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.jsoncrons 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-cronorbullmqinstalled with no purge job defined (dependency presence is not a mechanism); a retention doc whose periods are allTBD, that names no data classes, or that declares every class indefinite (no bounded period anywhere is not a retention schedule); RedisEXPIREused 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.jsoncrons entry (orcron.scheduleline) 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.jsoncrons entry pointing atapp/api/cron/purge-expired/route.tsthat deletes rows older than your retention window, or a pg_croncron.schedulemigration undersupabase/migrations/), a database-level TTL (expireAfterSeconds, DynamoDB TTL, RedisEXPIRE), or commitdocs/ops/data-retention.mdnaming each data class and its period. For user-initiated erasure (a related but distinct obligation), see thegdpr-readinessaudit.
External references
- soc2:2017 · C1.2 — Confidentiality — disposal of confidential information
- iso-27001:2022 · A.8.10 — Information deletion
- nist:rev5 · SI-12 — Information Management and Retention
Taxons
History
- 2026-07-03·v1.0.0·Initial soc2-readiness v1 authoring — bounded retention via scheduled purge, database-level TTL, or a committed retention schedule doc, kept explicitly distinct from user-initiated account deletion.·by soc2-readiness-v1-build