GDPR right to erasure edge cases: audit logs, references, and shadow accounts

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;
  }
}
Email addresses are PII even after hashing (they can be re-hashed to find the original). A request to erase data means the email address must be gone from your primary database, your email marketing system, your support ticketing system, and any analytics warehouse that has ingested it. Build an inventory of all systems that receive email addresses before implementing your erasure flow.

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.

← Back to blog Try Bastionary free →