From bb7723cc35341ceeb7269a010feffdfc1b52b727 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:24:43 -0800 Subject: [PATCH] refactor: improved change email verification flow (#6088) --- docs/content/docs/concepts/users-accounts.mdx | 43 ++++++--- docs/content/docs/reference/options.mdx | 7 +- .../src/api/routes/email-verification.test.ts | 31 +++++- .../src/api/routes/email-verification.ts | 64 +++++++++++++ .../src/api/routes/update-user.test.ts | 94 +++++++++++-------- .../better-auth/src/api/routes/update-user.ts | 58 ++++++++++-- .../plugins/phone-number/phone-number.test.ts | 2 + packages/core/src/types/init-options.ts | 20 ++++ 8 files changed, 256 insertions(+), 63 deletions(-) diff --git a/docs/content/docs/concepts/users-accounts.mdx b/docs/content/docs/concepts/users-accounts.mdx index 270dadeda1..293d69bee2 100644 --- a/docs/content/docs/concepts/users-accounts.mdx +++ b/docs/content/docs/concepts/users-accounts.mdx @@ -35,18 +35,22 @@ export const auth = betterAuth({ }) ``` -For users with a verified email, provide the `sendChangeEmailVerification` function. This function triggers when a user changes their email, sending a verification email with a URL and token. If the current email isn't verified, the change happens immediately without verification. +By default, when a user requests to change their email, a verification email is sent to the **new** email address. The email is only updated after the user verifies the new email. + +#### Confirming with Current Email + +For added security, you can require users to confirm the change via their **current** email before the verification email is sent to the new address. To do this, provide the `sendChangeEmailConfirmation` function. ```ts export const auth = betterAuth({ user: { changeEmail: { enabled: true, - sendChangeEmailVerification: async ({ user, newEmail, url, token }, request) => { + sendChangeEmailConfirmation: async ({ user, newEmail, url, token }, request) => { await sendEmail({ - to: user.email, // verification email must be sent to the current user email to approve the change + to: user.email, // Sent to the CURRENT email subject: 'Approve email change', - text: `Click the link to approve the change: ${url}` + text: `Click the link to approve the change to ${newEmail}: ${url}` }) } } @@ -54,20 +58,35 @@ export const auth = betterAuth({ }) ``` -Once enabled, use the `changeEmail` function on the client to update a user’s email. The user must verify their current email before changing it. +#### Updating Without Verification + +If you want to allow users to update their email immediately without verification (only if their current email is NOT verified), you can enable `updateEmailWithoutVerification`. + +```ts +export const auth = betterAuth({ + user: { + changeEmail: { + enabled: true, + updateEmailWithoutVerification: true + } + } +}) +``` + + + If `updateEmailWithoutVerification` is false (default), the email will not be updated until the new email is verified, even if the current email is unverified. + + +#### Client Usage + +Use the `changeEmail` function on the client to initiate the process. ```ts await authClient.changeEmail({ newEmail: "new-email@email.com", - callbackURL: "/dashboard", //to redirect after verification + callbackURL: "/dashboard", // to redirect after verification }); ``` - -After verification, the new email is updated in the user table, and a confirmation is sent to the new address. - - - If the current email is unverified, the new email is updated without the verification step. - ### Change Password A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches: diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 00a9e13f0f..9f60726dea 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -273,9 +273,10 @@ export const auth = betterAuth({ }, changeEmail: { enabled: true, - sendChangeEmailVerification: async ({ user, newEmail, url, token }) => { - // Send change email verification - } + sendChangeEmailConfirmation: async ({ user, newEmail, url, token }) => { + // Send change email confirmation to the old email + }, + updateEmailWithoutVerification: false // Update email without verification if user is not verified }, deleteUser: { enabled: true, diff --git a/packages/better-auth/src/api/routes/email-verification.test.ts b/packages/better-auth/src/api/routes/email-verification.test.ts index bbcc3d3ddf..5d2518d261 100644 --- a/packages/better-auth/src/api/routes/email-verification.test.ts +++ b/packages/better-auth/src/api/routes/email-verification.test.ts @@ -341,23 +341,46 @@ describe("Email Verification Secondary Storage", async () => { }, headers, }); - const newHeaders = new Headers(); + + // 1. Verify confirmation token (sent to old email) + const confirmationHeaders = new Headers(); await client.verifyEmail({ query: { token, }, fetchOptions: { - onSuccess: cookieSetter(newHeaders), + onSuccess: cookieSetter(confirmationHeaders), headers, }, }); + + // Check that email is NOT updated yet + const sessionAfterConfirmation = await client.getSession({ + fetchOptions: { + headers: confirmationHeaders, + }, + }); + expect(sessionAfterConfirmation.data?.user.email).toBe(testUser.email); + + // 2. Verify new email token (token variable was updated by sendVerificationEmail mock) + const verificationHeaders = new Headers(); + await client.verifyEmail({ + query: { + token, + }, + fetchOptions: { + onSuccess: cookieSetter(verificationHeaders), + headers: confirmationHeaders, + }, + }); + const session = await client.getSession({ fetchOptions: { - headers: newHeaders, + headers: verificationHeaders, }, }); expect(session.data?.user.email).toBe("new@email.com"); - expect(session.data?.user.emailVerified).toBe(false); + expect(session.data?.user.emailVerified).toBe(true); }); }); diff --git a/packages/better-auth/src/api/routes/email-verification.ts b/packages/better-auth/src/api/routes/email-verification.ts index 1ce18c222f..8196fc6deb 100644 --- a/packages/better-auth/src/api/routes/email-verification.ts +++ b/packages/better-auth/src/api/routes/email-verification.ts @@ -22,11 +22,16 @@ export async function createEmailVerificationToken( * The time in seconds for the token to expire */ expiresIn: number = 3600, + /** + * Extra payload to include in the token + */ + extraPayload?: Record, ) { const token = await signJWT( { email: email.toLowerCase(), updateTo, + ...extraPayload, }, secret, expiresIn, @@ -294,6 +299,7 @@ export const verifyEmail = createAuthEndpoint( const schema = z.object({ email: z.email(), updateTo: z.string().optional(), + requestType: z.string().optional(), }); const parsed = schema.parse(jwt.payload); const user = await ctx.context.internalAdapter.findUserByEmail( @@ -317,6 +323,64 @@ export const verifyEmail = createAuthEndpoint( return redirectOnError("unauthorized"); } + if (parsed.requestType === "change-email-confirmation") { + const newToken = await createEmailVerificationToken( + ctx.context.secret, + parsed.email, + parsed.updateTo, + ctx.context.options.emailVerification?.expiresIn, + { + requestType: "change-email-verification", + }, + ); + const updateCallbackURL = ctx.query.callbackURL + ? encodeURIComponent(ctx.query.callbackURL) + : encodeURIComponent("/"); + const url = `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`; + await ctx.context.options.emailVerification?.sendVerificationEmail?.( + { + user: { + ...session.user, + email: parsed.updateTo, + }, + url, + token: newToken, + }, + ctx.request, + ); + if (ctx.query.callbackURL) { + throw ctx.redirect(ctx.query.callbackURL); + } + return ctx.json({ + status: true, + }); + } + + if (parsed.requestType === "change-email-verification") { + const updatedUser = await ctx.context.internalAdapter.updateUserByEmail( + parsed.email, + { + email: parsed.updateTo, + emailVerified: true, + }, + ); + await setSessionCookie(ctx, { + session: session.session, + user: { + ...session.user, + email: parsed.updateTo, + emailVerified: true, + }, + }); + if (ctx.query.callbackURL) { + throw ctx.redirect(ctx.query.callbackURL); + } + return ctx.json({ + status: true, + user: updatedUser, + }); + } + const updatedUser = await ctx.context.internalAdapter.updateUserByEmail( parsed.email, { 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 53ea3e7a4b..7913aeadac 100644 --- a/packages/better-auth/src/api/routes/update-user.test.ts +++ b/packages/better-auth/src/api/routes/update-user.test.ts @@ -54,31 +54,8 @@ describe("updateUser", async () => { }); }); - it("should update user email", async () => { - const newEmail = "new-email@email.com"; - await globalRunWithClient(async () => { - const res = await client.changeEmail({ - newEmail, - }); - const sessionRes = await client.getSession(); - expect(sessionRes.data?.user.email).toBe(newEmail); - expect(sessionRes.data?.user.emailVerified).toBe(false); - }); - }); - - it("should verify email", async () => { - await globalRunWithClient(async () => { - await client.verifyEmail({ - query: { - token: emailVerificationToken, - }, - }); - const sessionRes = await client.getSession(); - expect(sessionRes.data?.user.emailVerified).toBe(true); - }); - }); - - it("should send email verification before update", async () => { + it("should not update user email immediately (default secure flow)", async () => { + // Ensure user is verified to trigger the confirmation flow await db.update({ model: "user", update: { @@ -87,27 +64,68 @@ describe("updateUser", async () => { where: [ { field: "email", - value: "new-email@email.com", + value: testUser.email, }, ], }); + + const newEmail = "new-email@email.com"; await globalRunWithClient(async () => { - await client.changeEmail({ - newEmail: "new-email-2@email.com", + const res = await client.changeEmail({ + newEmail, }); + const sessionRes = await client.getSession(); + // Should NOT update email yet + expect(sessionRes.data?.user.email).not.toBe(newEmail); + expect(sessionRes.data?.user.email).toBe(testUser.email); + }); + }); + + it("should verify email change (flow with confirmation)", async () => { + // The previous test triggered changeEmail. + // Since testUser is verified, and sendChangeEmailVerification is provided, + // it should have sent a confirmation email to the OLD email. + + expect(sendChangeEmail).toHaveBeenCalled(); + const call = sendChangeEmail.mock.calls[0]; + const token = call?.[3]; // token is 4th arg + if (!token) throw new Error("Token not found"); + + await globalRunWithClient(async () => { + // 1. Verify the confirmation token (sent to old email) + const res = await client.verifyEmail({ + query: { + token: token, + }, + }); + expect(res.data?.status).toBe(true); + + // This should trigger sending verification to the NEW email. + // emailVerification.sendVerificationEmail should have been called. + // We captured this in emailVerificationToken variable in setup. + expect(emailVerificationToken).toBeDefined(); + + // User email should STILL be old email + const sessionRes = await client.getSession(); + expect(sessionRes.data?.user.email).toBe(testUser.email); + + // 2. Verify the new email token + const res2 = await client.verifyEmail({ + query: { + token: emailVerificationToken, + }, + }); + expect(res2.data?.status).toBe(true); + + // NOW user email should be updated + const sessionRes2 = await client.getSession(); + expect(sessionRes2.data?.user.email).toBe("new-email@email.com"); + expect(sessionRes2.data?.user.emailVerified).toBe(true); }); - expect(sendChangeEmail).toHaveBeenCalledWith( - expect.objectContaining({ - email: "new-email@email.com", - }), - "new-email-2@email.com", - expect.any(String), - expect.any(String), - ); }); it("should update the user's password", async () => { - const newEmail = "new-email@email.com"; + const newEmail = "new-email@email.com"; // User email is now this await globalRunWithClient(async () => { const updated = await client.changePassword({ newPassword: "newPassword", @@ -122,7 +140,7 @@ describe("updateUser", async () => { }); expect(signInRes.data?.user).toBeDefined(); const signInCurrentPassword = await client.signIn.email({ - email: testUser.email, + email: testUser.email, // Old email password: testUser.password, }); expect(signInCurrentPassword.data).toBeNull(); diff --git a/packages/better-auth/src/api/routes/update-user.ts b/packages/better-auth/src/api/routes/update-user.ts index c45228aaa5..79020bcae3 100644 --- a/packages/better-auth/src/api/routes/update-user.ts +++ b/packages/better-auth/src/api/routes/update-user.ts @@ -759,10 +759,14 @@ export const changeEmail = createAuthEndpoint( message: BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, }); } + /** - * If the email is not verified, we can update the email + * If the email is not verified, we can update the email if the option is enabled */ - if (ctx.context.session.user.emailVerified !== true) { + if ( + ctx.context.session.user.emailVerified !== true && + ctx.context.options.user.changeEmail.updateEmailWithoutVerification + ) { await ctx.context.internalAdapter.updateUserByEmail( ctx.context.session.user.email, { @@ -809,7 +813,44 @@ export const changeEmail = createAuthEndpoint( /** * If the email is verified, we need to send a verification email */ - if (!ctx.context.options.user.changeEmail.sendChangeEmailVerification) { + const sendConfirmationToOldEmail = + ctx.context.session.user.emailVerified && + (ctx.context.options.user.changeEmail.sendChangeEmailConfirmation || + ctx.context.options.user.changeEmail.sendChangeEmailVerification); + + if (sendConfirmationToOldEmail) { + const token = await createEmailVerificationToken( + ctx.context.secret, + ctx.context.session.user.email, + newEmail, + ctx.context.options.emailVerification?.expiresIn, + { + requestType: "change-email-confirmation", + }, + ); + const url = `${ + ctx.context.baseURL + }/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`; + const sendFn = + ctx.context.options.user.changeEmail.sendChangeEmailConfirmation || + ctx.context.options.user.changeEmail.sendChangeEmailVerification; + if (sendFn) { + await sendFn( + { + user: ctx.context.session.user, + newEmail: newEmail, + url, + token, + }, + ctx.request, + ); + } + return ctx.json({ + status: true, + }); + } + + if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw new APIError("BAD_REQUEST", { message: "Verification email isn't enabled", @@ -821,14 +862,19 @@ export const changeEmail = createAuthEndpoint( ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, + { + requestType: "change-email-verification", + }, ); const url = `${ ctx.context.baseURL }/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`; - await ctx.context.options.user.changeEmail.sendChangeEmailVerification( + await ctx.context.options.emailVerification.sendVerificationEmail( { - user: ctx.context.session.user, - newEmail: newEmail, + user: { + ...ctx.context.session.user, + email: newEmail, + }, url, token, }, diff --git a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts index c6b4b8d385..7487ad5ea0 100644 --- a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts +++ b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts @@ -122,6 +122,7 @@ describe("phone auth flow", async () => { user: { changeEmail: { enabled: true, + updateEmailWithoutVerification: true, }, }, }, @@ -206,6 +207,7 @@ describe("phone auth flow", async () => { email: newEmail, password: "password", }); + console.log(res); expect(res.error).toBe(null); }); }); diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index 4d96bfe583..7f33b95acc 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -617,6 +617,7 @@ export type BetterAuthOptions = { * Send a verification email when the user changes their email. * @param data the data object * @param request the request object + * @deprecated Use `sendChangeEmailConfirmation` instead */ sendChangeEmailVerification?: ( data: { @@ -627,6 +628,25 @@ export type BetterAuthOptions = { }, request?: Request, ) => Promise; + /** + * Send a confirmation email to the old email address when the user changes their email. + * @param data the data object + * @param request the request object + */ + sendChangeEmailConfirmation?: ( + data: { + user: User; + newEmail: string; + url: string; + token: string; + }, + request?: Request, + ) => Promise; + /** + * Update the email without verification if the user is not verified. + * @default false + */ + updateEmailWithoutVerification?: boolean; }; /** * User deletion configuration