From 3eaf3c540c0bbdb9196cba22af821b91ed2f29ee Mon Sep 17 00:00:00 2001 From: quochuydev Date: Thu, 12 Feb 2026 17:55:08 +0700 Subject: [PATCH] feat(user): add soft delete support to deleteUser Add a `softDelete` option that sets a `deletedAt` timestamp instead of permanently removing the user. Soft-deleted users are filtered from authentication lookups but can be hard-deleted by admins. --- .../src/api/routes/update-user.test.ts | 206 ++++++++++++++++++ .../better-auth/src/api/routes/update-user.ts | 39 +++- .../better-auth/src/db/internal-adapter.ts | 30 ++- .../better-auth/src/plugins/admin/routes.ts | 1 + .../__snapshots__/open-api.test.ts.snap | 4 + packages/core/src/db/get-tables.ts | 11 + packages/core/src/types/context.ts | 5 +- packages/core/src/types/init-options.ts | 9 + 8 files changed, 298 insertions(+), 7 deletions(-) diff --git a/packages/better-auth/src/api/routes/update-user.test.ts b/packages/better-auth/src/api/routes/update-user.test.ts index 7614090e18..b4a9324305 100644 --- a/packages/better-auth/src/api/routes/update-user.test.ts +++ b/packages/better-auth/src/api/routes/update-user.test.ts @@ -573,6 +573,212 @@ describe("delete user", async () => { }); }); + it("should soft delete user with deletedAt when softDelete is enabled", async () => { + const { client, signInWithTestUser, db } = await getTestInstance({ + user: { + deleteUser: { + enabled: true, + softDelete: true, + }, + }, + session: { + freshAge: 1000, + }, + }); + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => { + const session = await client.getSession(); + const userId = session.data!.user.id; + + const res = await client.deleteUser({ + softDelete: true, + }); + expect(res.data).toMatchObject({ + success: true, + }); + + // User should still exist in DB with deletedAt set + const dbUser = await db.findOne<{ deletedAt: Date | null }>({ + model: "user", + where: [{ field: "id", value: userId }], + }); + expect(dbUser).toBeDefined(); + expect(dbUser!.deletedAt).toBeDefined(); + expect(dbUser!.deletedAt).not.toBeNull(); + + // Session should be revoked + const sessionAfter = await client.getSession(); + expect(sessionAfter.data).toBeNull(); + }); + }); + + it("should prevent soft-deleted user from signing in", async () => { + const { client, signInWithTestUser, testUser } = await getTestInstance({ + user: { + deleteUser: { + enabled: true, + softDelete: true, + }, + }, + session: { + freshAge: 1000, + }, + }); + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => { + await client.deleteUser({ + softDelete: true, + }); + }); + + // Signing in should fail because user is soft-deleted + const signInRes = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + expect(signInRes.error).toBeDefined(); + }); + + it("should still hard delete by default even with softDelete config enabled", async () => { + const { client, signInWithTestUser, db } = await getTestInstance({ + user: { + deleteUser: { + enabled: true, + softDelete: true, + }, + }, + session: { + freshAge: 1000, + }, + }); + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => { + const session = await client.getSession(); + const userId = session.data!.user.id; + + const res = await client.deleteUser(); + expect(res.data).toMatchObject({ + success: true, + }); + + // User should be completely gone from DB + const dbUser = await db.findOne({ + model: "user", + where: [{ field: "id", value: userId }], + }); + expect(dbUser).toBeNull(); + }); + }); + + it("should hard delete when body has softDelete but config does not", async () => { + const { client, signInWithTestUser, db } = await getTestInstance({ + user: { + deleteUser: { + enabled: true, + }, + }, + session: { + freshAge: 1000, + }, + }); + const { runWithUser } = await signInWithTestUser(); + await runWithUser(async () => { + const session = await client.getSession(); + const userId = session.data!.user.id; + + const res = await client.deleteUser({ + softDelete: true, + }); + expect(res.data).toMatchObject({ + success: true, + }); + + // User should be hard deleted since config.softDelete is off + const dbUser = await db.findOne({ + model: "user", + where: [{ field: "id", value: userId }], + }); + expect(dbUser).toBeNull(); + }); + }); + + it("should allow admin to hard-delete a soft-deleted user", async () => { + const { admin } = await import("../../plugins/admin/admin"); + const { adminClient: adminClientPlugin } = await import( + "../../plugins/admin/client" + ); + const { client, signInWithTestUser, db, sessionSetter } = + await getTestInstance( + { + user: { + deleteUser: { + enabled: true, + softDelete: true, + }, + }, + plugins: [admin()], + session: { + freshAge: 1000, + }, + }, + { + clientOptions: { + plugins: [adminClientPlugin()], + }, + }, + ); + + // Sign up and soft-delete a user + const { runWithUser } = await signInWithTestUser(); + let userId = ""; + await runWithUser(async () => { + const session = await client.getSession(); + userId = session.data!.user.id; + await client.deleteUser({ + softDelete: true, + }); + }); + + // Verify user is soft-deleted in DB + const dbUser = await db.findOne<{ deletedAt: Date | null }>({ + model: "user", + where: [{ field: "id", value: userId }], + }); + expect(dbUser).toBeDefined(); + expect(dbUser!.deletedAt).not.toBeNull(); + + // Create an admin user + const adminHeaders = new Headers(); + await client.signUp.email({ + email: "admin@test.com", + password: "admin-password", + name: "Admin", + fetchOptions: { + onSuccess: sessionSetter(adminHeaders), + }, + }); + // Set admin role directly + await db.update({ + model: "user", + update: { role: "admin" }, + where: [{ field: "email", value: "admin@test.com" }], + }); + + // Admin hard-deletes the soft-deleted user + const removeRes = await client.admin.removeUser( + { userId }, + { headers: adminHeaders }, + ); + expect(removeRes.data?.success).toBe(true); + + // User should now be completely gone + const dbUserAfter = await db.findOne({ + model: "user", + where: [{ field: "id", value: userId }], + }); + expect(dbUserAfter).toBeNull(); + }); + it("should ignore cookie cache for sensitive operations like changePassword", async () => { const { client: cacheClient, sessionSetter: cacheSessionSetter } = await getTestInstance( diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index 4fad330a3b..cff9756605 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -401,6 +401,17 @@ export const deleteUser = createAuthEndpoint( description: "The token to delete the user is required", }) .optional(), + /** + * If true and soft delete is enabled in config, the user will be + * soft-deleted (deletedAt timestamp set) instead of permanently removed. + */ + softDelete: z + .boolean() + .meta({ + description: + "Soft delete the user instead of permanently removing them", + }) + .optional(), }), metadata: { openapi: { @@ -426,6 +437,11 @@ export const deleteUser = createAuthEndpoint( type: "string", description: "The deletion verification token", }, + softDelete: { + type: "boolean", + description: + "Soft delete the user instead of permanently removing them", + }, }, }, }, @@ -549,7 +565,16 @@ export const deleteUser = createAuthEndpoint( if (beforeDelete) { await beforeDelete(session.user, ctx.request); } - await ctx.context.internalAdapter.deleteUser(session.user.id); + const isSoftDelete = + ctx.body.softDelete === true && + ctx.context.options.user?.deleteUser?.softDelete === true; + if (isSoftDelete) { + await ctx.context.internalAdapter.updateUser(session.user.id, { + deletedAt: new Date(), + }); + } else { + await ctx.context.internalAdapter.deleteUser(session.user.id); + } await ctx.context.internalAdapter.deleteSessions(session.user.id); deleteSessionCookie(ctx); const afterDelete = ctx.context.options.user.deleteUser?.afterDelete; @@ -640,9 +665,17 @@ export const deleteUserCallback = createAuthEndpoint( if (beforeDelete) { await beforeDelete(session.user, ctx.request); } - await ctx.context.internalAdapter.deleteUser(session.user.id); + const isSoftDelete = + ctx.context.options.user?.deleteUser?.softDelete === true; + if (isSoftDelete) { + await ctx.context.internalAdapter.updateUser(session.user.id, { + deletedAt: new Date(), + }); + } else { + await ctx.context.internalAdapter.deleteUser(session.user.id); + await ctx.context.internalAdapter.deleteAccounts(session.user.id); + } await ctx.context.internalAdapter.deleteSessions(session.user.id); - await ctx.context.internalAdapter.deleteAccounts(session.user.id); await ctx.context.internalAdapter.deleteVerificationValue(token.id); deleteSessionCookie(ctx); diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index f547a4c542..6c01a61fd5 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -43,6 +43,7 @@ export const createInternalAdapter = ( ): InternalAdapter => { const logger = ctx.logger; const options = ctx.options; + const softDeleteEnabled = options.user?.deleteUser?.softDelete === true; const secondaryStorage = options.secondaryStorage; const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days const { @@ -760,8 +761,13 @@ export const createInternalAdapter = ( user: true, }, }); + const isSoftDeleted = (u: Record) => + softDeleteEnabled && u?.deletedAt != null; if (account) { if (account.user) { + if (isSoftDeleted(account.user)) { + return null; + } return { user: account.user, linkedAccount: account, @@ -777,7 +783,7 @@ export const createInternalAdapter = ( }, ], }); - if (user) { + if (user && !isSoftDeleted(user)) { return { user, linkedAccount: account, @@ -796,7 +802,7 @@ export const createInternalAdapter = ( }, ], }); - if (user) { + if (user && !isSoftDeleted(user)) { const accounts = await ( await getCurrentAdapter(adapter) ).findMany({ @@ -837,6 +843,13 @@ export const createInternalAdapter = ( ...(options?.includeAccounts ? { account: true } : {}), }, }); + if ( + result && + softDeleteEnabled && + (result as Record).deletedAt != null + ) { + return null; + } if (!result) return null; const { account: accounts, ...user } = result; return { @@ -844,7 +857,10 @@ export const createInternalAdapter = ( accounts: accounts ?? [], }; }, - findUserById: async (userId: string) => { + findUserById: async ( + userId: string, + opts?: { includeSoftDeleted?: boolean }, + ) => { if (!userId) return null; const user = await (await getCurrentAdapter(adapter)).findOne({ model: "user", @@ -855,6 +871,14 @@ export const createInternalAdapter = ( }, ], }); + if ( + user && + softDeleteEnabled && + !opts?.includeSoftDeleted && + (user as Record).deletedAt != null + ) { + return null; + } return user; }, linkAccount: async ( diff --git a/packages/better-auth/src/plugins/admin/routes.ts b/packages/better-auth/src/plugins/admin/routes.ts index 6e079acee6..0397eac2c9 100644 --- a/packages/better-auth/src/plugins/admin/routes.ts +++ b/packages/better-auth/src/plugins/admin/routes.ts @@ -1443,6 +1443,7 @@ export const removeUser = (opts: AdminOptions) => const user = await ctx.context.internalAdapter.findUserById( ctx.body.userId, + { includeSoftDeleted: true }, ); if (!user) { diff --git a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap index 7a41b07831..511290a533 100644 --- a/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap +++ b/packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap @@ -1010,6 +1010,10 @@ exports[`open-api > should generate OpenAPI schema > openAPISchema 1`] = ` "description": "The user's password. Required if session is not fresh", "type": "string", }, + "softDelete": { + "description": "Soft delete the user instead of permanently removing them", + "type": "boolean", + }, "token": { "description": "The deletion verification token", "type": "string", diff --git a/packages/core/src/db/get-tables.ts b/packages/core/src/db/get-tables.ts index a06cc42a49..535bb73ab7 100644 --- a/packages/core/src/db/get-tables.ts +++ b/packages/core/src/db/get-tables.ts @@ -192,6 +192,17 @@ export const getAuthTables = ( required: true, fieldName: options.user?.fields?.updatedAt || "updatedAt", }, + ...(options.user?.deleteUser?.softDelete + ? { + deletedAt: { + type: "date" as const, + required: false, + input: false, + returned: true, + fieldName: "deletedAt", + }, + } + : {}), ...user?.fields, ...options.user?.additionalFields, }, diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 1b0ba69aeb..69dfef8bd9 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -160,7 +160,10 @@ export interface InternalAdapter< options?: { includeAccounts: boolean } | undefined, ): Promise<{ user: User; accounts: Account[] } | null>; - findUserById(userId: string): Promise; + findUserById( + userId: string, + options?: { includeSoftDeleted?: boolean }, + ): Promise; linkAccount( account: Omit & Partial, diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index 91dfb07dbb..7c4d652289 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -679,6 +679,15 @@ export type BetterAuthOptions = { * Enable user deletion */ enabled?: boolean; + /** + * Enable soft delete. When enabled, calling `deleteUser` with + * `softDelete: true` will set a `deletedAt` timestamp instead + * of permanently removing the user. A `deletedAt` column is + * added to the user table when this option is `true`. + * + * Soft-deleted users cannot authenticate. + */ + softDelete?: boolean; /** * Send a verification email when the user deletes their account. *