# Users can request permanent deletion of their personal data and account

- **Pattern:** `ab-001502` (`gdpr-readiness.user-rights.right-to-erasure`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001502
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001502)

## Why it matters

GDPR Art. 17 — the 'right to be forgotten' — requires permanent deletion of personal data when the user requests it and no overriding legal basis for retention exists. A soft-delete that sets a `deleted_at` flag without a subsequent purge job leaves PII sitting indefinitely in your database, which is a direct Art. 17 violation every day after the grace period expires. Cascade failures are equally common: deleting the user row while leaving activity logs, uploaded files, and messages orphaned with the original `user_id` and any associated PII is not erasure. Regulators do not accept 'the data is inaccessible through the UI' as equivalent to deletion.

## Severity rationale

High because failing to implement actual erasure — not just soft-delete — keeps PII in your database beyond the user's exercise of their Art. 17 right, constituting ongoing unlawful retention under Art. 5(1)(e).

## Remediation

Implement two-phase deletion: immediate access revocation followed by a cron-triggered full purge after a documented grace period (typically 14–30 days).

```ts
// Phase 1: app/api/user/delete/route.ts — revoke access immediately
export async function DELETE() {
  const session = await getServerSession()
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })
  await db.user.update({
    where: { id: session.user.id },
    data: { email: `deleted-${session.user.id}@purged.invalid`, name: 'Deleted User', deletedAt: new Date() }
  })
  await db.session.deleteMany({ where: { userId: session.user.id } })
  return Response.json({ ok: true })
}
```

```ts
// Phase 2: app/api/cron/purge-deleted-users/route.ts — run after grace period
export async function GET() {
  const cutoff = new Date(Date.now() - 30 * 86400_000)
  const toDelete = await db.user.findMany({ where: { deletedAt: { lt: cutoff } }, select: { id: true } })
  for (const { id } of toDelete) {
    await db.$transaction([
      db.activityLog.deleteMany({ where: { userId: id } }),
      db.consentRecord.deleteMany({ where: { userId: id } }),
      db.order.updateMany({ where: { userId: id }, data: { userId: null, customerEmail: 'purged@deleted.invalid' } }),
      db.user.delete({ where: { id } }),
    ])
  }
  return Response.json({ purged: toDelete.length })
}
```

Also delete uploaded files from object storage inside the purge loop. Document any data retained beyond the grace period (e.g., financial records for 7 years under tax law) with its Art. 6(1)(c) legal obligation basis.

## Detection

- **ID:** `right-to-erasure`
- **Severity:** `high`
- **What to look for:** Find the account deletion feature, typically in user settings under "Account," "Privacy," or "Danger Zone." Verify whether deletion is a hard delete (immediate removal) or soft delete (sets a `deleted_at` flag). If soft-delete, check whether a cron job or scheduled function permanently purges data after a grace period. Verify whether deletion cascades to related tables (orders, activity logs, uploaded files, messages, consent records, audit logs containing PII). Check whether there is a confirmation step making clear that deletion is irreversible. Also check for any data that might be retained beyond the grace period: financial records may legitimately be retained longer under legal obligation (tax/accounting requirements), but this must be documented. Count every data store and third-party service that holds personal data. Enumerate which implement deletion and which retain data after an erasure request.
- **Pass criteria:** Users can delete their account from settings with a confirmation step. After deletion (or after a documented grace period — typically 14-30 days), all PII is permanently removed or anonymized. Deletion cascades through all related tables. Any data legitimately retained beyond the grace period (e.g., financial records) is retained under a documented legal obligation basis and limited to the minimum necessary. At least 1 implementation must be confirmed.
- **Fail criteria:** No account deletion feature exists. Deletion only sets `deleted_at` with no automated permanent purge. Related tables orphan PII after account deletion. No confirmation step warning users that deletion is irreversible. Do NOT pass if erasure only soft-deletes records (sets a deleted_at flag) without confirming actual removal from all data stores.
- **Skip (N/A) when:** Application has no user accounts.
- **Cross-reference:** The `data-retention-enforcement` check in Data Processing verifies that automated retention limits complement manual erasure requests.
- **Detail on fail:** Example: `"No account deletion option in user settings."` or `"Delete account sets deleted_at=true but no cron job purges data — PII persists indefinitely."` or `"User row is deleted but activity logs and uploaded files retain the user_id and associated PII."`.
- **Remediation:** Implement two-phase deletion with full cascade:

  ```ts
  // Phase 1: Immediate soft delete — user loses access
  // app/api/user/delete/route.ts
  export async function DELETE() {
    const session = await getServerSession()
    if (!session?.user?.id) return new Response('Unauthorized', { status: 401 })

    // Anonymize immediately visible fields; schedule full purge
    await db.user.update({
      where: { id: session.user.id },
      data: {
        email: `deleted-${session.user.id}@purged.invalid`,
        name: 'Deleted User',
        deletedAt: new Date(),
      }
    })

    // Revoke all active sessions
    await db.session.deleteMany({ where: { userId: session.user.id } })
    return Response.json({ ok: true, message: 'Account deletion scheduled.' })
  }

  // Phase 2: Full purge — run via cron after 30-day grace period
  // app/api/cron/purge-deleted-users/route.ts
  export async function GET() {
    const cutoff = new Date()
    cutoff.setDate(cutoff.getDate() - 30)

    const toDelete = await db.user.findMany({
      where: { deletedAt: { lt: cutoff } },
      select: { id: true }
    })

    for (const { id } of toDelete) {
      await db.$transaction([
        db.activityLog.deleteMany({ where: { userId: id } }),
        db.consentRecord.deleteMany({ where: { userId: id } }),
        db.userPreference.deleteMany({ where: { userId: id } }),
        // Anonymize orders (retain for accounting, remove PII)
        db.order.updateMany({
          where: { userId: id },
          data: { userId: null, customerEmail: 'purged@deleted.invalid' }
        }),
        db.user.delete({ where: { id } }),
      ])
      // Also delete uploaded files from object storage here
    }

    return Response.json({ purged: toDelete.length })
  }
  ```

---

## External references

- gdpr Art. 17
- gdpr Art. 5(1)(e)
- cwe CWE-1272

Taxons: privacy-consent, data-integrity

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