Users can request permanent deletion of their personal data and account
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).
// 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 })
}
// 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_atflag). 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_atwith 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-enforcementcheck 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:
// 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 — Right to erasure ('right to be forgotten')
- gdpr · Art. 5(1)(e) — Storage limitation principle
- cwe · CWE-1272 — Sensitive resource not removed after period of prescribed use
Taxons
History
- 2026-04-18·v1.0.0·Initial import from gdpr-readiness·automated