Users can permanently delete their account and all associated PII
Why it matters
GDPR Art. 17 (right to erasure), CCPA §1798.105, and LGPD Art. 18(VI) all require that users can request permanent deletion of their personal data. A soft-delete flag with no automated purge fulfills none of these obligations — the PII still exists in the database and in backups. ISO-27001:2022 A.8.10 requires documented and implemented media disposal and data deletion controls. The practical risk: a soft-deleted user's email and personal data sitting in your database is indistinguishable from active data to a database attacker or a regulatory auditor. Orphaned records in orders, activity logs, and uploaded files that reference a deleted user's ID also constitute retained PII that should have been erased.
Severity rationale
Low because the right to deletion is user-triggered rather than an automatic systemic exposure, but non-compliance with a submitted deletion request is a direct Art. 17 violation.
Remediation
Implement a two-phase deletion: immediate soft-delete that revokes access, followed by a cron-driven permanent purge after the grace period.
// Phase 1: app/api/user/delete/route.ts — immediate soft delete
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: { deletedAt: new Date(), email: null, name: 'Deleted User' }
})
return Response.json({ ok: true })
}
// Phase 2: app/api/cron/purge-deleted/route.ts — permanent purge after 30 days
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 user of toDelete) {
await db.$transaction([
db.activityLog.deleteMany({ where: { userId: user.id } }),
db.order.deleteMany({ where: { userId: user.id } }),
db.user.delete({ where: { id: user.id } }),
])
}
return Response.json({ purged: toDelete.length })
}
Add a "Delete Account" button in user settings with a confirmation modal that states what will be deleted and when the purge completes. Schedule the purge cron in vercel.json or your equivalent scheduler.
Detection
-
ID:
account-deletion -
Severity:
low -
What to look for: Enumerate every relevant item. Find the account deletion feature, typically in user settings under "Account," "Privacy," or "Danger Zone." Verify the implementation: does deletion hard-delete user data, or only soft-delete (set a
deleted_atflag)? If soft-delete, check whether there is an automated process to permanently purge data after a grace period (typically 30 days). Does deletion cascade to related tables (orders, activity logs, uploaded files, messages)? Is there a confirmation step warning the user that deletion is permanent? Are there orphaned records left behind (e.g., analytics events with the user's ID that are never cleaned up)? -
Pass criteria: At least 1 of the following conditions is met. Users can delete their account from a settings page. A confirmation step warns that deletion is irreversible. After deletion (or after a documented grace period), all associated PII is permanently removed from the database. Related records in all tables are deleted or anonymized. Uploaded files in storage are removed.
-
Fail criteria: No account deletion feature exists. Deletion only sets a
deleted_atflag with no automated permanent deletion. Related records (orders, logs) are not cleaned up, leaving orphaned PII. -
Skip (N/A) when: Application has no user accounts.
-
Detail on fail: Example:
"No account deletion option found in user settings."or"Delete account sets deleted_at flag but no cron job exists to permanently purge data after the 30-day grace period."or"Deletion deletes the user row but orphans orders and activity logs containing PII.". -
Remediation: Implement a two-phase deletion with cleanup:
// Phase 1: Immediate soft delete (user-triggered) // app/api/user/delete/route.ts export async function DELETE() { const session = await getServerSession() if (!session?.user?.id) return new Response('Unauthorized', { status: 401 }) // Mark for deletion — user loses access immediately await db.user.update({ where: { id: session.user.id }, data: { deletedAt: new Date(), email: null, name: 'Deleted User' } }) // Revoke session return Response.json({ ok: true }) } // Phase 2: Permanent 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 user of toDelete) { // Cascade: delete all associated data await db.$transaction([ db.activityLog.deleteMany({ where: { userId: user.id } }), db.order.deleteMany({ where: { userId: user.id } }), db.userPreferences.deleteMany({ where: { userId: user.id } }), db.user.delete({ where: { id: user.id } }), ]) // Also delete from file storage (S3, Supabase Storage, etc.) } return Response.json({ purged: toDelete.length }) }Add a "Delete Account" button in settings with a confirmation modal that explains the 30-day grace period and what will be deleted.
External references
- gdpr · Art. 17 — Right to erasure ('right to be forgotten')
- ccpa · §1798.105 — Consumer right to deletion of personal information
- lgpd · Art. 18(VI) — Right to deletion of personal data processed with consent
- iso-27001:2022 · A.8.10 — Information deletion
Taxons
History
- 2026-04-18·v1.0.0·Initial import from data-protection·automated