GDPR Article 17 gives EU data subjects the right to request erasure of their personal data. The legal text sounds simple. The engineering implementation is not. A real user account is woven through your database: referenced in audit logs, tied to billing records, the author of content, the initiator of actions that affected other users. Deleting the user row while leaving all of those references intact is not erasure — it is just account deactivation. This post covers the actual hard cases.
What "erasure" means legally
The GDPR does not require destroying every byte ever associated with the person. It requires that personal data — data from which the individual can be identified — be erased when the legal basis for processing it no longer exists. Several legitimate exceptions allow retention:
- Legal obligation: financial records that must be retained for 7 years under tax law, fraud investigation records required by regulators.
- Legitimate interest: security audit logs that record what actions were taken on your system (though these must be proportionate and time-limited).
- Vital interests: medical or safety data in narrow circumstances.
- Public interest or scientific research: rare in commercial SaaS contexts.
In practice for SaaS auth systems: you can generally retain that an action occurred and what action it was, but not who performed it. Pseudonymization — replacing the user identifier with a stable but non-identifying token — satisfies erasure for most audit log use cases.
Pseudonymization for audit logs
Audit logs cannot simply be deleted — they record security-relevant events (logins, role changes, data exports) that you need for forensic purposes. The solution is to replace the user reference with a pseudonymous identifier that cannot be linked back to the individual after erasure.
-- Audit log structure with pseudonymization support CREATE TABLE audit_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), occurred_at TIMESTAMPTZ NOT NULL, action TEXT NOT NULL, -- 'user.login', 'role.changed', etc. actor_id UUID, -- NULL after erasure actor_pseudo TEXT, -- pseudonym, set during erasure target_id UUID, target_type TEXT, metadata JSONB, -- may contain PII — must be scrubbed ip_address INET, -- must be erased (PII under GDPR) user_agent TEXT -- must be erased (PII under GDPR) );
// Erasure: pseudonymize audit log references
async function eraseUserFromAuditLogs(userId) {
// Generate a stable pseudonym for this user's audit history
// Using HMAC means the pseudonym is consistent across the audit table
// but cannot be reversed to find the original user ID
const pseudoKey = Buffer.from(process.env.AUDIT_PSEUDO_KEY, 'base64');
const pseudonym = createHmac('sha256', pseudoKey)
.update(userId)
.digest('hex')
.substring(0, 12); // short for readability: "deleted-a3b9c1d2e4f5"
await db.query(`
UPDATE audit_events
SET
actor_id = NULL,
actor_pseudo = $2,
ip_address = NULL,
user_agent = NULL,
metadata = metadata - 'email' - 'name' - 'phone' -- remove PII keys
WHERE actor_id = $1
`, [userId, `deleted-${pseudonym}`]);
}
// Separate from pseudonymization: scrub user data from JSONB metadata
async function scrubMetadataPII(userId) {
// Find any metadata field that contains the user's email or name
// This is necessarily application-specific
await db.query(`
UPDATE audit_events
SET metadata = metadata - 'user_email' - 'user_name'
WHERE metadata->>'user_id' = $1
`, [userId]);
}
Cascade deletion order
When actually deleting user data (not pseudonymizing), the order of operations matters. Foreign key constraints will block deletion of the user row while dependent rows exist. You need to delete or nullify dependent data first:
// Deletion order for a typical auth system user
async function eraseUser(userId) {
await db.query('BEGIN');
try {
// 1. Revoke all sessions and tokens first (security: prevent further access)
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [userId]);
await db.query('DELETE FROM personal_access_tokens WHERE user_id = $1', [userId]);
// 2. Remove MFA enrollments
await db.query('DELETE FROM mfa_credentials WHERE user_id = $1', [userId]);
await db.query('DELETE FROM passkeys WHERE user_id = $1', [userId]);
// 3. Remove OAuth connections
await db.query('DELETE FROM oauth_connections WHERE user_id = $1', [userId]);
// 4. Remove org memberships (notify org admins separately)
await db.query('DELETE FROM org_members WHERE user_id = $1', [userId]);
// 5. Pseudonymize audit logs (retain the log, erase PII)
await eraseUserFromAuditLogs(userId);
// 6. Handle billing: may need to transfer to org, or retain for 7 years
await transferOrRetainBillingRecords(userId);
// 7. Finally delete the user row
await db.query('DELETE FROM users WHERE id = $1', [userId]);
// 8. Record the erasure event itself (no PII — just "a user was erased")
await db.query(
'INSERT INTO erasure_log (erased_at, user_hash) VALUES (NOW(), $1)',
[hashUserId(userId)]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
Shadow accounts and invite references
Shadow accounts are created when you send an invitation to an email address before the recipient has registered. You store the email to track the invite status. If the user never registers and later exercises their right to erasure of marketing data, that email address in your invitations table is PII you are obligated to erase.
-- Invitations may contain PII for users who never registered
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
invitee_email TEXT NOT NULL, -- PII: must be erasable
invited_by UUID REFERENCES users(id),
accepted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
token_hash TEXT NOT NULL
);
-- Erasure for non-registered invitees
-- These users may not be in your users table at all
async function eraseInviteeData(email) {
await db.query(
'DELETE FROM invitations WHERE invitee_email = $1 AND accepted_at IS NULL',
[email]
);
// If invite was accepted, the user_id links to their account — handled by full erasure
}
The 30-day window
GDPR Article 12(3) gives you one month to respond to an erasure request, with a possible 2-month extension for complex cases. In practice, for most SaaS applications, erasure should be automated and complete within 48 hours. Build an erasure queue that processes requests asynchronously, sends a confirmation email when complete, and records the completion timestamp. The confirmation email itself should not contain the user's data — just confirmation that erasure is complete. You are required to be able to demonstrate completion if a supervisory authority requests it.