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.
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.
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.
ID: data-protection.user-rights-access.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_at flag)? 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_at flag 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.