refactor: improved change email verification flow (#6088)

This commit is contained in:
Bereket Engida
2025-11-19 00:24:43 -08:00
committed by GitHub
parent da9657e53b
commit bb7723cc35
8 changed files with 256 additions and 63 deletions

View File

@@ -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 users 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
}
}
})
```
<Callout type="warn">
If `updateEmailWithoutVerification` is false (default), the email will not be updated until the new email is verified, even if the current email is unverified.
</Callout>
#### 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.
<Callout type="warn">
If the current email is unverified, the new email is updated without the verification step.
</Callout>
### 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:

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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<string, any>,
) {
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,
{

View File

@@ -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();

View File

@@ -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,
},

View File

@@ -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);
});
});

View File

@@ -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<void>;
/**
* 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<void>;
/**
* Update the email without verification if the user is not verified.
* @default false
*/
updateEmailWithoutVerification?: boolean;
};
/**
* User deletion configuration