Change email flow problem

Flows: 1. The user enters the new email address, and the backend uses the auth.api.changeEmail API to trigger the sending of a verification email. 2. The user receives the change email verification email. 3. The user clicks the email verification link. 4. The user receives the error "Verify email failed". 5. The database changes the user's email from the old address to the new one. (This is unexpected: Why did the database update the email despite the "Verify email failed" error?) 6. The user then receives another email, which appears to be triggered by the sendVerificationEmail function. (This is also unexpected: Why was sendVerificationEmail triggered when the sendChangeEmailVerification process should have been the one requiring verification?) 7. The user clicks the email link again and receives the same "Verify email failed" error, but the database updates the email_verified status to true.
6 Replies
Ping
Ping4mo ago
Hey can you send your auth config? I'll see if I can repro.
Davis
DavisOP4mo ago
Better auth v1.2.9
Davis
DavisOP4mo ago
My own verify email function
export const verifyEmail = v1.auth.verifyEmail.handler(
async ({ input, context, errors }) => {
const { auth } = await import('@/lib/better-auth')

if (input.query.token && input.query.type) {
const verifyEmail = await catchAsync(() =>
auth().api.verifyEmail({
headers: context.c.req.raw.headers,
query: { token: input.query.token, callbackURL: input.query.next },
returnHeaders: true
})
)

const url = new URL(input.query.next)

if (!verifyEmail.success || !verifyEmail.data) {
const { capitalize, startCase } = await import('es-toolkit')

url.searchParams.set('status', 'failed')
url.searchParams.set(
'title',
(verifyEmail.error as APIError)?.body?.code || 'Error'
)
url.searchParams.set(
'message',
capitalize(
startCase(verifyEmail.error.message || 'Verify email failed')
)
)

return {
redirectTo: url.toString(),
success: false
}
}

const cookies = verifyEmail.data.headers.getSetCookie()

cookies.forEach((cookie) => {
context.resHeaders?.append('set-cookie', cookie)
})

url.searchParams.set('status', 'success')

return {
redirectTo: url.toString(),
success: true
}
}

throw errors.NOT_FOUND({ message: 'Invalid verification link' })
}
)
export const verifyEmail = v1.auth.verifyEmail.handler(
async ({ input, context, errors }) => {
const { auth } = await import('@/lib/better-auth')

if (input.query.token && input.query.type) {
const verifyEmail = await catchAsync(() =>
auth().api.verifyEmail({
headers: context.c.req.raw.headers,
query: { token: input.query.token, callbackURL: input.query.next },
returnHeaders: true
})
)

const url = new URL(input.query.next)

if (!verifyEmail.success || !verifyEmail.data) {
const { capitalize, startCase } = await import('es-toolkit')

url.searchParams.set('status', 'failed')
url.searchParams.set(
'title',
(verifyEmail.error as APIError)?.body?.code || 'Error'
)
url.searchParams.set(
'message',
capitalize(
startCase(verifyEmail.error.message || 'Verify email failed')
)
)

return {
redirectTo: url.toString(),
success: false
}
}

const cookies = verifyEmail.data.headers.getSetCookie()

cookies.forEach((cookie) => {
context.resHeaders?.append('set-cookie', cookie)
})

url.searchParams.set('status', 'success')

return {
redirectTo: url.toString(),
success: true
}
}

throw errors.NOT_FOUND({ message: 'Invalid verification link' })
}
)
do you repro the problem? I notice if I don't pass the callbackURL everything works fine
momoneko
momoneko4mo ago
bump encountering the exact same problem
Ping
Ping4mo ago
Does your issue relate to getting a 302 error? This specific issue the user is facing is already solved otherwise.
momoneko
momoneko4mo ago
No, I'm getting a 401 Unauthorized error. I'll try setting up a minimal repro code. The repro code
import { betterAuth } from "better-auth";
import { toNodeHandler } from "better-auth/node";
import Database from "better-sqlite3";
import express from "express";

const app = express();
const port = 8888;

export const auth = betterAuth({
database: new Database("./sqlite.db"),
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url, token }, request) => {
console.log({
email: user.email,
url,
token,
});
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailVerification: async (
{ user, newEmail, url, token },
request,
) => {
console.log({
oldEmail: user.email,
url,
newEmail,
token,
});
},
},
},
emailAndPassword: {
enabled: true,
},
});

app.all("/api/auth/*name", toNodeHandler(auth));

app.listen(port, () => {
console.log(`Better Auth app listening on port ${port}`);
});
import { betterAuth } from "better-auth";
import { toNodeHandler } from "better-auth/node";
import Database from "better-sqlite3";
import express from "express";

const app = express();
const port = 8888;

export const auth = betterAuth({
database: new Database("./sqlite.db"),
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url, token }, request) => {
console.log({
email: user.email,
url,
token,
});
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailVerification: async (
{ user, newEmail, url, token },
request,
) => {
console.log({
oldEmail: user.email,
url,
newEmail,
token,
});
},
},
},
emailAndPassword: {
enabled: true,
},
});

app.all("/api/auth/*name", toNodeHandler(auth));

app.listen(port, () => {
console.log(`Better Auth app listening on port ${port}`);
});
How to test it out
# before starting everything else, do migrate :)
npx @better-auth/cli@latest migrate
# start the server
ts-node server.ts
#signup with a new user
curl -c /tmp/cookies.txt -v -X POST localhost:8888/api/auth/sign-up/email --json '{"email": "new-user@example.com", "password": "helloWorld123", "name": "noname"}'
#sign-in with the user, just to prove that it worked
curl -c /tmp/cookies.txt -v -X POST localhost:8888/api/auth/sign-in/email --json '{"email": "new-user@example.com", "password": "helloWorld123"}'

# on sign-up, there should've been a verification email url printed in the console
# open it in the browser


# try to change email
curl -b /tmp/cookies.txt -v -X POST localhost:8888/api/auth/change-email --json '{"newEmail": "hello@world.com"}'
# url should've been printed in the console
# open it, and get UNAUTHORIZED
# before starting everything else, do migrate :)
npx @better-auth/cli@latest migrate
# start the server
ts-node server.ts
#signup with a new user
curl -c /tmp/cookies.txt -v -X POST localhost:8888/api/auth/sign-up/email --json '{"email": "new-user@example.com", "password": "helloWorld123", "name": "noname"}'
#sign-in with the user, just to prove that it worked
curl -c /tmp/cookies.txt -v -X POST localhost:8888/api/auth/sign-in/email --json '{"email": "new-user@example.com", "password": "helloWorld123"}'

# on sign-up, there should've been a verification email url printed in the console
# open it in the browser


# try to change email
curl -b /tmp/cookies.txt -v -X POST localhost:8888/api/auth/change-email --json '{"newEmail": "hello@world.com"}'
# url should've been printed in the console
# open it, and get UNAUTHORIZED
just read more carefully through the OP's code; seems like my case was different, using a different flow will open a different thread

Did you find this page helpful?