[GH-ISSUE #9099] feat(admin): user soft delete with anonymization and restore #28596

Open
opened 2026-04-17 20:02:13 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @mcorbelli on GitHub (Apr 10, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9099

Is this suited for github?

  • Yes, this is suited for github

When an admin deletes a user via admin.removeUser, the user record is permanently removed from the database. This creates several problems:

  • Audit trail loss: any foreign-key references (logs, activity records, etc.) that pointed to that user become dangling or must be cascade-deleted, destroying historical data.
  • No recourse: once deleted there is no way to recover the user or re-enable access without manually reconstructing the record.
  • GDPR/privacy compliance: a hard delete satisfies the “right to erasure” but leaves no trace for internal audit requirements. A soft delete with anonymization satisfies both: the record is retained (for referential integrity) but PII is scrubbed.

PR 9093

Describe the solution you'd like

Add a softDelete option to user.deleteUser (the existing user self-delete config object) and to the admin plugin’s removeUser endpoint.

When softDelete: true:

  1. admin.removeUser

    • Instead of deleting the user row, sets deletedAt = now() and overwrites PII fields (name, email, image) with anonymous placeholders.
    • Linked accounts and sessions are still removed.
    • A custom anonymizeUser(user) callback can override the default anonymization.
    • Reserved system fields (id, createdAt, updatedAt, emailVerified, deletedAt) are protected and cannot be overwritten by the callback.
  2. Sign-in blocked

    • A session.create.before hook rejects session creation for any user whose deletedAt is set, returning FORBIDDEN / USER_DELETED.
  3. admin.listUsers

    • Excludes soft-deleted users by default.
    • An includeDeleted query param allows admins to include them.
  4. admin.restoreUser

    • New endpoint that clears deletedAt, making the user record active again.
    • Because accounts are removed during deletion, the admin must separately set a new password or the user must re-link an OAuth provider to regain sign-in.
  5. Self-delete (user.deleteUser)

    • The same softDelete flag applies to the user’s own deleteUser flow (immediate delete and email-confirmation-based delete).
  6. DB migration

    • deletedAt is added to the core user table schema (via getAuthTables) when softDelete: true, so migrate / generate create the column automatically.

Schema / API Surface

// auth.ts
export const auth = betterAuth({
  user: {
    deleteUser: {
      enabled: true,
      softDelete: true,
      anonymizeUser: (user) => ({   // optional
        name: "Deleted User",
        email: `deleted-${user.id}@deleted.invalid`,
      }),
    },
  },
  plugins: [admin()],
});

// client
await authClient.admin.removeUser({ userId });   // soft-deletes
await authClient.admin.restoreUser({ userId });  // clears deletedAt
await authClient.admin.listUsers({ query: { includeDeleted: true } });

Describe alternatives you've considered

No response

Additional context

  • The deletedAt field is contributed to the user table both by the admin plugin schema (for admin-plugin users) and conditionally by getAuthTables (for non-admin-plugin users who still use softDelete).
  • z.coerce.boolean() is avoided for the includeDeleted query param because it mishandles the string "false"; a z.union with explicit "true" / "false" transform is used instead.
  • All new behaviour is covered by tests:
    • sign-in blocked
    • listUsers filtering
    • restoreUser
    • custom anonymizeUser
    • reserved-field protection
    • permission checks
  • Follows the same permission model as existing admin endpoints:
    • user: ["delete"] for removeUser
    • new user: ["restore"] for restoreUser
Originally created by @mcorbelli on GitHub (Apr 10, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9099 ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. When an admin deletes a user via `admin.removeUser`, the user record is permanently removed from the database. This creates several problems: - **Audit trail loss:** any foreign-key references (logs, activity records, etc.) that pointed to that user become dangling or must be cascade-deleted, destroying historical data. - **No recourse:** once deleted there is no way to recover the user or re-enable access without manually reconstructing the record. - **GDPR/privacy compliance:** a hard delete satisfies the “right to erasure” but leaves no trace for internal audit requirements. A soft delete with anonymization satisfies both: the record is retained (for referential integrity) but PII is scrubbed. [PR 9093](https://github.com/better-auth/better-auth/pull/9093) ### Describe the solution you'd like Add a `softDelete` option to `user.deleteUser` (the existing user self-delete config object) and to the admin plugin’s `removeUser` endpoint. When `softDelete: true`: 1. **`admin.removeUser`** - Instead of deleting the user row, sets `deletedAt = now()` and overwrites PII fields (`name`, `email`, `image`) with anonymous placeholders. - Linked accounts and sessions are still removed. - A custom `anonymizeUser(user)` callback can override the default anonymization. - Reserved system fields (`id`, `createdAt`, `updatedAt`, `emailVerified`, `deletedAt`) are protected and cannot be overwritten by the callback. 2. **Sign-in blocked** - A `session.create.before` hook rejects session creation for any user whose `deletedAt` is set, returning `FORBIDDEN / USER_DELETED`. 3. **`admin.listUsers`** - Excludes soft-deleted users by default. - An `includeDeleted` query param allows admins to include them. 4. **`admin.restoreUser`** - New endpoint that clears `deletedAt`, making the user record active again. - Because accounts are removed during deletion, the admin must separately set a new password or the user must re-link an OAuth provider to regain sign-in. 5. **Self-delete (`user.deleteUser`)** - The same `softDelete` flag applies to the user’s own `deleteUser` flow (immediate delete and email-confirmation-based delete). 6. **DB migration** - `deletedAt` is added to the core user table schema (via `getAuthTables`) when `softDelete: true`, so `migrate` / `generate` create the column automatically. --- ## Schema / API Surface ```ts // auth.ts export const auth = betterAuth({ user: { deleteUser: { enabled: true, softDelete: true, anonymizeUser: (user) => ({ // optional name: "Deleted User", email: `deleted-${user.id}@deleted.invalid`, }), }, }, plugins: [admin()], }); // client await authClient.admin.removeUser({ userId }); // soft-deletes await authClient.admin.restoreUser({ userId }); // clears deletedAt await authClient.admin.listUsers({ query: { includeDeleted: true } }); ``` ### Describe alternatives you've considered _No response_ ### Additional context - The `deletedAt` field is contributed to the user table both by the admin plugin schema (for admin-plugin users) and conditionally by `getAuthTables` (for non-admin-plugin users who still use `softDelete`). - `z.coerce.boolean()` is avoided for the `includeDeleted` query param because it mishandles the string `"false"`; a `z.union` with explicit `"true"` / `"false"` transform is used instead. - All new behaviour is covered by tests: - sign-in blocked - `listUsers` filtering - `restoreUser` - custom `anonymizeUser` - reserved-field protection - permission checks - Follows the same permission model as existing admin endpoints: - `user: ["delete"]` for `removeUser` - new `user: ["restore"]` for `restoreUser`
Author
Owner

@bytaesu commented on GitHub (Apr 10, 2026):

Hi @mcorbelli, we won't be making changes to the core schema at the moment. Is there another approach to achieve what you're trying to implement?

<!-- gh-comment-id:4223620641 --> @bytaesu commented on GitHub (Apr 10, 2026): Hi @mcorbelli, we won't be making changes to the core schema at the moment. Is there another approach to achieve what you're trying to implement?
Author
Owner

@mcorbelli commented on GitHub (Apr 12, 2026):

Thanks for the reply @bytaesu ! I've identified an alternative approach that avoids touching the core schema, while still keeping the original direction in mind.


Option A — Standalone Soft-Delete Plugin

Create a new plugins/soft-delete/ plugin (similar to plugins/anonymous/) that:

  • Adds deletedAt to its own schema (not core)
  • Intercepts deleteUser via databaseHooks
  • Blocks sign-in for deleted users via databaseHooks.session.create.before
  • Works independently, while still being composable with the admin plugin

Pros:

  • Clean architecture
  • Zero core schema changes
  • Fully opt-in
  • Composable across plugins

Original Approach (Current PR)

The original implementation:

  • Adds deletedAt only when softDelete: true is explicitly set in user.deleteUser
  • Is fully opt-in — no schema changes unless enabled
  • Keeps behavior consistent across admin-triggered deletion and self-service deletion flows

In my view, this is still the better integration overall, because it keeps the feature close to the existing user lifecycle and provides a more natural developer experience.

At the same time, I believe it is also relatively low impact, since the schema change is minimal and only applied when the feature is explicitly enabled.


If core schema changes are off the table for now, I can rework this into a standalone plugin instead.

<!-- gh-comment-id:4231667632 --> @mcorbelli commented on GitHub (Apr 12, 2026): Thanks for the reply @bytaesu ! I've identified an alternative approach that avoids touching the core schema, while still keeping the original direction in mind. --- ## Option A — Standalone Soft-Delete Plugin Create a new `plugins/soft-delete/` plugin (similar to `plugins/anonymous/`) that: - Adds `deletedAt` to its own schema (not core) - Intercepts `deleteUser` via `databaseHooks` - Blocks sign-in for deleted users via `databaseHooks.session.create.before` - Works independently, while still being composable with the admin plugin **Pros:** - Clean architecture - Zero core schema changes - Fully opt-in - Composable across plugins --- ## Original Approach (Current PR) The original implementation: - Adds `deletedAt` only when `softDelete: true` is explicitly set in `user.deleteUser` - Is fully opt-in — no schema changes unless enabled - Keeps behavior consistent across admin-triggered deletion and self-service deletion flows In my view, this is still the **better integration overall**, because it keeps the feature close to the existing user lifecycle and provides a more natural developer experience. At the same time, I believe it is also **relatively low impact**, since the schema change is minimal and only applied when the feature is explicitly enabled. --- If core schema changes are off the table for now, I can rework this into a standalone plugin instead.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28596