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