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.
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).
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.
ID: gdpr-readiness.user-rights.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:
// 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 })
}