Back to Engineering Blog

Building GDPR-Compliant User Deletion: A Full-Stack Approach

How we implemented user deletion at Homi with PII anonymization, analytics provider updates, and proper dependency handling—all while maintaining data integrity.

Kristian Elset Bø

Kristian Elset Bø

Engineer

12 min read
#engineering#privacy#gdpr#backend

Building User Deletion the Right Way

User deletion is one of those features that seems simple on the surface but reveals its complexity once you start implementing it. At Homi, we needed to build a deletion system that respects user privacy, complies with GDPR regulations, maintains data integrity, and works seamlessly across multiple services.

Here's how we did it—and what we learned along the way.

GDPR Context: The General Data Protection Regulation (GDPR) gives users the "right to be forgotten"—meaning they can request deletion of their personal data. While this doesn't always mean complete data deletion, it requires careful handling of Personally Identifiable Information (PII).

The Challenge: More Than Just a DELETE Statement

When a user requests account deletion, you can't just run DELETE FROM users WHERE id = ? and call it a day. Here's why:

  1. Referential Integrity: Users often own or created data that other users depend on
  2. Analytics History: You need to preserve analytics while removing PII
  3. Third-Party Services: User data lives in multiple systems (PostHog, Loops, Polar, etc.)
  4. Compliance: GDPR requires PII removal, not necessarily all data deletion
  5. Blob Storage: Profile images and uploads need cleanup

Our Approach: Soft Delete + PII Anonymization

We chose a hybrid approach that balances compliance with data integrity:

// Soft delete with PII anonymization
const [anonymizedUser] = await db
  .update(userTable)
  .set({
    deletedAt: new Date(),
    // Anonymize all PII but keep the ID for referential integrity
    email: `deleted-${userId}@deleted.local`,
    name: null,
    displayName: null,
    image: null,
    phone: null,
    address: null,
    city: null,
    state: null,
    zipCode: null,
    country: null,
    gender: null,
    birthDate: null,
    nationality: null,
  })
  .where(eq(userTable.id, userId))
  .returning();

Why this works:

  • ✅ Removes all PII (GDPR compliant)
  • ✅ Preserves user ID for foreign key relationships
  • ✅ Maintains historical analytics data
  • ✅ Prevents data cascade issues

Best Practice: Use soft deletion with PII anonymization instead of hard deletion. This preserves referential integrity while still respecting user privacy.

The OAuth Security Gotcha

Here's a critical detail: soft deletion alone isn't enough to prevent re-login.

When we soft delete a user, we update the user record but leave related tables untouched. The problem? Database cascade deletes only trigger on hard deletes (SQL DELETE), not updates.

This means:

  • Email/Password Login: Blocked (email is now deleted-{userId}@deleted.local)
  • ⚠️ OAuth Login (Google, etc.): NOT blocked - the account record still exists!

If a deleted user tries to "Sign in with Google," the auth system would:

  1. Receive OAuth response from Google
  2. Find their existing account record (still in the database)
  3. Link back to their user (with deletedAt set)
  4. Create a new session - no check for deleted users!

The Fix: Explicit Account & Session Deletion

We explicitly hard delete account and session records:

// Hard delete all account records (prevents OAuth re-login)
await db.delete(accountTable).where(eq(accountTable.userId, userId));

// Hard delete all session records (terminates all active sessions)
await db.delete(sessionTable).where(eq(sessionTable.userId, userId));

// THEN soft delete user with PII anonymization
const [anonymizedUser] = await db.update(userTable).set({...});

Why this works:

  • ✅ Deleted users can't log in via any method (OAuth or password)
  • ✅ All active sessions are immediately terminated
  • ✅ User record still exists for referential integrity
  • ✅ Clean, predictable security behavior

Important: Always explicitly delete account and session records during user deletion. Don't rely on cascade deletes with soft deletion - they won't trigger!

Dependency Checking: Protecting Data Integrity

Before deleting a user, we check what they own:

export async function checkUserDeletionDependencies(
  userId: string,
): Promise<UserDeletionCheck> {
  const [ownedCollections, createdListings] = await Promise.all([
    // Count collections owned by user
    db.$count(
      collectionTable,
      and(
        eq(collectionTable.ownerId, userId),
        isNull(collectionTable.deletedAt),
      ),
    ),
    // Count property listings created by user
    db.$count(
      propertyListingTable,
      and(
        eq(propertyListingTable.creatorId, userId),
        isNull(propertyListingTable.deletedAt),
      ),
    ),
  ]);

  const blockers: string[] = [];

  if (ownedCollections > 0) {
    blockers.push(`owns ${ownedCollections} active collections`);
  }

  return {
    canDelete: blockers.length === 0,
    ownedCollections,
    createdListings,
    blockers,
  };
}

This prevents users from deleting their accounts while they still own collections that other users collaborate on. They must first transfer ownership or delete their collections.

Multi-Service Cleanup: The Full Picture

User data doesn't just live in our database. Here's how we handle all the pieces:

1. Blob Storage Cleanup

Profile images and uploads need to be removed from Vercel Blob:

// Clean up user profile image from blob storage
if (userToDelete.image?.includes("blob.vercel-storage.com")) {
  try {
    await BlobService.delete(userToDelete.image);
    console.log(`🗑️ Cleaned up deleted user's profile image`);
  } catch (error) {
    console.error(`⚠️ Failed to delete user profile image blob:`, error);
  }
}

2. Polar Customer Deletion

We fully delete users from Polar (our billing provider):

async function deletePolarCustomer(userId: string): Promise<void> {
  const polarClient = new Polar({
    accessToken: env_auth.POLAR_ACCESS_TOKEN,
    server: env_auth.POLAR_SERVER,
  });

  try {
    await polarClient.customers.deleteExternal({
      externalId: userId,
    });
    console.log(`✅ Deleted Polar customer: ${userId}`);
  } catch (error) {
    // Handle customer not found gracefully
    if (error.message.includes("not found")) {
      console.log(`ℹ️ Polar customer not found (likely never created)`);
      return;
    }
    console.error(`⚠️ Failed to delete Polar customer:`, error);
  }
}

3. Analytics Provider Updates (PostHog & Loops)

Here's where it gets interesting. We don't delete users from analytics providers—we update them with anonymized data:

// Update analytics providers with anonymized data
identifyServerUser(userId, anonymizedUser);

// Track user deletion event
trackServerEvent(userId, "user_deleted", undefined);

Why update instead of delete?

  1. Historical Analytics: Preserve event history and cohort analysis
  2. Referential Integrity: Past events still reference a valid user ID
  3. Deletion Analytics: Track deletion patterns and reasons
  4. Compliance: Anonymized data meets GDPR requirements

Our identifyServerUser function updates three providers:

const serverAnalyticsProviders = [
  {
    id: "posthog",
    identifyServerUser: identifyPosthogUser,
  },
  {
    id: "loops",
    identifyServerUser: (userId: string, traits: Partial<UserTraits>) => {
      const email = traits.email!; // Now deleted-{userId}@deleted.local
      return createOrUpdateLoopsContact(email, { ...traits, userId });
    },
  },
  {
    id: "vercel",
    // Vercel Analytics doesn't have identify method
  },
];

Important: If your jurisdiction requires complete data deletion from all systems, you'll need to use PostHog's delete person API and Loops' delete contact functionality instead of anonymization.

Two Entry Points, One Service

We implemented user deletion in two places:

1. User Self-Delete (User Router)

// packages/api/src/router/userRouter.ts
delete: protectedProcedure
  .input(z.string())
  .mutation(async ({ ctx, input }) => {
    const deletionCheck = await checkUserDeletionDependencies(input);

    if (!deletionCheck.canDelete) {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: `Cannot delete user: ${deletionCheck.blockers.join(", ")}`,
      });
    }

    const { anonymizedUser } = await deleteUser(input);
    trackServerEvent(ctx.session.user.id, "user_deleted", undefined);

    return anonymizedUser;
  })

2. Admin Delete (Admin Router)

// packages/api/src/router/adminRouter.ts
deleteUser: homiAdminProcedure
  .input(z.object({ userId: z.string() }))
  .mutation(async ({ ctx, input }) => {
    // Prevent admins from deleting themselves
    if (input.userId === ctx.session.user.id) {
      throw new TRPCError({
        code: "BAD_REQUEST",
        message: "Cannot delete your own account",
      });
    }

    const deletionCheck = await checkUserDeletionDependencies(input.userId);
    // ... same deletion logic
  });

Both routes call the same deleteUser() service, ensuring consistency.

The UI: Making Deletion Safe and Clear

We built two deletion interfaces:

User Account Settings (Danger Zone)

At /app/account, users see a red "Danger Zone" card:

<Card className="border-destructive">
  <CardHeader>
    <CardTitle className="text-destructive">Danger Zone</CardTitle>
    <p className="text-muted-foreground text-sm">
      Irreversible and destructive actions for your account.
    </p>
  </CardHeader>
  <CardContent>
    <div className="flex flex-col space-y-4">
      <p className="text-sm">
        Once you delete your account, there is no going back. All your personal
        data will be permanently anonymized. You must delete or transfer
        ownership of all collections you own before deleting your account.
      </p>
      <DeleteAccountButton userId={session.user.id} />
    </div>
  </CardContent>
</Card>

Features:

  • ✅ Clear warning about irreversibility
  • ✅ Explains the blocker (must delete collections first)
  • ✅ Confirmation dialog
  • ✅ Auto sign-out after deletion

Admin Panel

At /admin/users, admins can delete users with:

  • User cards showing account details
  • Dropdown menu with "Delete User" option
  • Confirmation dialog
  • Safety check: can't delete own account

Key Takeaways

  1. Choose Anonymization Over Hard Delete: Preserves data integrity while respecting privacy
  2. Explicitly Delete Accounts & Sessions: Soft deletion doesn't trigger cascades - manually delete auth records
  3. Check Dependencies First: Prevent orphaned data and cascade issues
  4. Update Analytics, Don't Delete: Maintain historical insights with anonymized profiles
  5. Clean Up Everywhere: Don't forget blob storage and third-party services
  6. Make It Safe: Use confirmation dialogs and clear warnings
  7. Single Source of Truth: Multiple entry points should call the same service

Result: A GDPR-compliant deletion system that respects user privacy, maintains data integrity, and works consistently across all services.

The Code is Open

Want to see the full implementation? Check out our deletion service to see how we:

  • Handle dependency checking
  • Manage multi-service cleanup
  • Implement PII anonymization
  • Track deletion analytics

Get started today

No more waiting, the search for your next home starts now. Join thousands of families who've made their home hunting easier with Homi.

Homi Platform Screenshot

Comments and Feedback

Have questions about our implementation? Want to discuss GDPR compliance strategies? Reach out to us at engineering@homi.so or book a chat.

We're always learning and improving our approach to privacy and data protection. If you've implemented user deletion differently, we'd love to hear about it!

About the Author

Kristian Elset Bø

Kristian Elset Bø

Engineering at Homi, building the future of real estate technology.

Related Posts

Continue reading with these related articles

Kristian Elset BøKristian Elset Bø

Still No UI Survives First Contact: A Sequel

Remember when we redesigned our Add Property dialog and wrote about it? Turns out that design also didn't survive. Here's how we got it right (this time, we think).

#engineering#ui-design#user-feedback#iteration
Kristian Elset BøKristian Elset Bø

Email Audience Segmentation Without Schema Pollution

How we built a campaign-ready sync system for Loops that computes dynamic user segments on-demand without polluting our database schema or scattering one-off updates throughout our codebase.

#engineering#email-marketing#backend#data-architecture
Kristian Elset BøKristian Elset Bø

No UI Survives First Contact with Users

How we rebuilt our 'Add Property' dialog three times in one session based on real user feedback. A case study in iterative design and the importance of staying flexible.

#engineering#ui-design#user-feedback#iteration

Want our product updates? Sign up for our newsletter.

We care about your data. Read our privacy policy.