auth.api.changeEmail updates email immediately even when user is verified (unlike client flow) #1512

Closed
opened 2026-03-13 08:44:29 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @daviduzondu on GitHub (Jul 17, 2025).

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Set up Better Auth with changeEmail.enabled: true and a working sendChangeEmailVerification callback.

  2. Use the server-side API:

    await auth.api.changeEmail({
      body: { newEmail: "[email protected]" },
      headers: request.headers
    });
    
    
  3. Call this from a server route where the user has a verified current email.

  4. Observe that:

  • The verification email is sent to the current email.
  • But the email field on the user is updated immediately, without waiting for link approval.

Current vs. Expected behavior

Expected:
Just like the client (authClient.changeEmail), the email should not be updated until the user clicks the link in the verification email sent by sendChangeEmailVerification.

Actual:
The email is immediately changed server-side even though the user was verified.

What version of Better Auth are you using?

1.2.9

Provide environment information

- OS: Ubuntu 24.04
- Browser: N/A (issue is server-side)

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

betterAuth({
          advanced: {
            database: {
              generateId: false,
            },
          },
          emailVerification: {
            sendVerificationEmail: async ({ user, url, token }, request) => {
              console.log('Second verification email sent!', user, url, token);
              // await sendEmail({ to: user.email, subject: 'Verify your email', text: `Click here: ${url}` });
            },
          },
          user: {
            changeEmail: {
              enabled: true,
              sendChangeEmailVerification: async ({ user, url, token }, request) => {
                console.log('First verification email sent!', user, url, token);
                // await sendEmail({ to: user.email, subject: 'Verify your email', text: `Click here: ${url}` });
              },
            },
          },
          account: {
            accountLinking: {
              enabled: false,
            },
          },
          trustedOrigins: [process.env.FE_BASE!],
          rateLimit: {
            window: 100,
            max: 1,
            enabled: true,
            modelName: 'rate_limit',
            customRules: {
              '/sign-in/email': {
                window: 10,
                max: 3,
              },
            },
          },
          database: {
            db: kyselyInstance,
            type: 'postgres',
          },
          plugins: [openAPI(), normalizeEmail()],
          emailAndPassword: {
            enabled: true,
          },
          socialProviders: {
            google: {
              clientId: process.env.GOOGLE_CLIENT_ID!,
              clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
              redirectURI: `${process.env.BASE_URL}/api/auth/callback/google`,
              enabled: true,
            },
          },
        })

Additional context

No response

Originally created by @daviduzondu on GitHub (Jul 17, 2025). Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Set up Better Auth with `changeEmail.enabled: true` and a working `sendChangeEmailVerification` callback. 2. Use the server-side API: ```ts await auth.api.changeEmail({ body: { newEmail: "[email protected]" }, headers: request.headers }); 3. Call this from a server route where the user has a verified current email. 4. Observe that: - The verification email is sent to the current email. - But the email field on the user is updated immediately, without waiting for link approval. ### Current vs. Expected behavior Expected: Just like the client (`authClient.changeEmail`), the email should not be updated until the user clicks the link in the verification email sent by sendChangeEmailVerification. Actual: The email is immediately changed server-side even though the user was verified. ### What version of Better Auth are you using? 1.2.9 ### Provide environment information ```bash - OS: Ubuntu 24.04 - Browser: N/A (issue is server-side) ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript betterAuth({ advanced: { database: { generateId: false, }, }, emailVerification: { sendVerificationEmail: async ({ user, url, token }, request) => { console.log('Second verification email sent!', user, url, token); // await sendEmail({ to: user.email, subject: 'Verify your email', text: `Click here: ${url}` }); }, }, user: { changeEmail: { enabled: true, sendChangeEmailVerification: async ({ user, url, token }, request) => { console.log('First verification email sent!', user, url, token); // await sendEmail({ to: user.email, subject: 'Verify your email', text: `Click here: ${url}` }); }, }, }, account: { accountLinking: { enabled: false, }, }, trustedOrigins: [process.env.FE_BASE!], rateLimit: { window: 100, max: 1, enabled: true, modelName: 'rate_limit', customRules: { '/sign-in/email': { window: 10, max: 3, }, }, }, database: { db: kyselyInstance, type: 'postgres', }, plugins: [openAPI(), normalizeEmail()], emailAndPassword: { enabled: true, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, redirectURI: `${process.env.BASE_URL}/api/auth/callback/google`, enabled: true, }, }, }) ``` ### Additional context _No response_
GiteaMirror added the bug label 2026-03-13 08:44:29 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 17, 2025):

Hi @daviduzondu! I'm Dosu and I’m helping the better-auth team.

This is not intended behavior—when a user's email is verified, calling auth.api.changeEmail should only send a verification email and wait for the user to approve the change via the verification link. The email should not be updated immediately; only after the verification link is clicked should the change be applied. This matches both the documented and implemented flow for both client and server APIs (docs, code).

If you're seeing the email update immediately for verified users, it's likely due to a bug, misconfiguration, or possibly a custom adapter issue. Double-check your config, make sure you're on the correct version, and verify that your database adapter isn't overriding the default logic. If the issue persists, try isolating it with a minimal setup to rule out external factors.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

@dosubot[bot] commented on GitHub (Jul 17, 2025): <!-- Greeting --> Hi @daviduzondu! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is not intended behavior—when a user's email is verified, calling `auth.api.changeEmail` should only send a verification email and wait for the user to approve the change via the verification link. The email should not be updated immediately; only after the verification link is clicked should the change be applied. This matches both the documented and implemented flow for both client and server APIs ([docs](https://github.com/better-auth/better-auth/blob/0c4583c015807077cbded89e089f59f7d3d67b48/docs/content/docs/concepts/users-accounts.mdx), [code](https://github.com/better-auth/better-auth/blob/0c4583c015807077cbded89e089f59f7d3d67b48/packages/better-auth/src/api/routes/update-user.ts)). If you're seeing the email update immediately for verified users, it's likely due to a bug, misconfiguration, or possibly a custom adapter issue. Double-check your config, make sure you're on the correct version, and verify that your database adapter isn't overriding the default logic. If the issue persists, try isolating it with a minimal setup to rule out external factors. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/74842e0b-4e1d-4ba6-9532-a80e6a98eba1?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3424)
Author
Owner

@AymeneBahmed commented on GitHub (Jul 21, 2025):

I'm facing the same issue except that for me changeEmail.sendChangeEmailVerification is not even getting executed (I checked that by adding a console.log statement). For some reason, it works but very rarely.

I'm using Next.js version 15.2.3. Maybe it has something to do with the hot module replacement? For example, in case of Prisma, Next.js creates multiple instances of PrismaClient, so you'll have to do something like this (from their offical docs):

import { PrismaClient } from '../app/generated/prisma'
import { withAccelerate } from '@prisma/extension-accelerate'

const globalForPrisma = global as unknown as { 
    prisma: PrismaClient
}

const prisma = globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate())

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

export default prisma

Maybe this is the problem, but I am not sure.

EDIT: no, HMR is not the problem. I tried this, but nothing works:


import { betterAuth, Session, User } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { emailOTP } from "better-auth/plugins";
import { prisma } from "../prisma";
import {
  sendChangeEmailVerificationMail,
  sendDeleteUserVerificationMail,
  sendEmailVerificationMail,
} from "../mail";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";

const globalForAuth = global as unknown as {
  auth: ReturnType<typeof betterAuth>;
};

export const auth =
  globalForAuth.auth ||
  betterAuth({
    database: prismaAdapter(prisma, {
      provider: "postgresql",
    }),
    socialProviders: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID as string,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
      },
    },
    plugins: [
      emailOTP({
        async sendVerificationOTP({ email, otp, type }) {
          if (type === "email-verification") {
            await sendEmailVerificationMail(email, otp);

            (await cookies()).set("email", email, {
              httpOnly: true,
              secure: process.env.NODE_ENV === "production",
              path: "/",
              maxAge: 300,
              sameSite: "lax",
            });
          }
        },
        sendVerificationOnSignUp: true,
        disableSignUp: true,
      }),
    ],
    emailVerification: {
      autoSignInAfterVerification: true,
      async onEmailVerification() {
        (await cookies()).delete("email");
      },
    },
    emailAndPassword: {
      enabled: true,
      autoSignIn: false,
      requireEmailVerification: true,
    },
    user: {
      deleteUser: {
        enabled: true,
        async sendDeleteAccountVerification({ user, url }) {
          await sendDeleteUserVerificationMail(user.email, url);
        },
      },
      changeEmail: {
        enabled: true,
        async sendChangeEmailVerification({ user, newEmail, url }) {
          console.log("Sending...");

          await sendChangeEmailVerificationMail(user.email, url);
        },
      },
    },
  });

if (process.env.NODE_ENV !== "production") globalForAuth.auth = auth;
@AymeneBahmed commented on GitHub (Jul 21, 2025): I'm facing the same issue except that for me `changeEmail.sendChangeEmailVerification` is not even getting executed (I checked that by adding a `console.log` statement). For some reason, it works but very rarely. I'm using Next.js version 15.2.3. Maybe it has something to do with the hot module replacement? For example, in case of Prisma, Next.js creates multiple instances of `PrismaClient`, so you'll have to do something like this (from their [offical docs](https://www.prisma.io/docs/guides/nextjs#25-set-up-prisma-client)): ```TS import { PrismaClient } from '../app/generated/prisma' import { withAccelerate } from '@prisma/extension-accelerate' const globalForPrisma = global as unknown as { prisma: PrismaClient } const prisma = globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate()) if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma export default prisma ``` Maybe this is the problem, but I am not sure. EDIT: no, HMR is not the problem. I tried this, but nothing works: ```TS import { betterAuth, Session, User } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { emailOTP } from "better-auth/plugins"; import { prisma } from "../prisma"; import { sendChangeEmailVerificationMail, sendDeleteUserVerificationMail, sendEmailVerificationMail, } from "../mail"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; const globalForAuth = global as unknown as { auth: ReturnType<typeof betterAuth>; }; export const auth = globalForAuth.auth || betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, plugins: [ emailOTP({ async sendVerificationOTP({ email, otp, type }) { if (type === "email-verification") { await sendEmailVerificationMail(email, otp); (await cookies()).set("email", email, { httpOnly: true, secure: process.env.NODE_ENV === "production", path: "/", maxAge: 300, sameSite: "lax", }); } }, sendVerificationOnSignUp: true, disableSignUp: true, }), ], emailVerification: { autoSignInAfterVerification: true, async onEmailVerification() { (await cookies()).delete("email"); }, }, emailAndPassword: { enabled: true, autoSignIn: false, requireEmailVerification: true, }, user: { deleteUser: { enabled: true, async sendDeleteAccountVerification({ user, url }) { await sendDeleteUserVerificationMail(user.email, url); }, }, changeEmail: { enabled: true, async sendChangeEmailVerification({ user, newEmail, url }) { console.log("Sending..."); await sendChangeEmailVerificationMail(user.email, url); }, }, }, }); if (process.env.NODE_ENV !== "production") globalForAuth.auth = auth; ```
Author
Owner

@camdowney commented on GitHub (Aug 8, 2025):

Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem?

@camdowney commented on GitHub (Aug 8, 2025): Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem?
Author
Owner

@camdowney commented on GitHub (Aug 12, 2025):

Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem?

Looks like the issue stopped for me when I disabled the session.cookieCache auth config option.

@camdowney commented on GitHub (Aug 12, 2025): > Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem? Looks like the issue stopped for me when I disabled the session.cookieCache auth config option.
Author
Owner

@AymeneBahmed commented on GitHub (Aug 14, 2025):

Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem?

Looks like the issue stopped for me when I disabled the session.cookieCache auth config option.

I actually had to write custom logic as a workaround for this issue. It was a lot of work, but at least it's working now.

@AymeneBahmed commented on GitHub (Aug 14, 2025): > > Having the same issue as you @AymeneBahmed - sendChangeEmailVerification is usually not called. Ever figure out the problem? > > Looks like the issue stopped for me when I disabled the session.cookieCache auth config option. I actually had to write custom logic as a workaround for this issue. It was a lot of work, but at least it's working now.
Author
Owner

@wottpal commented on GitHub (Aug 28, 2025):

Same issue here (not even the client routes are working). Email verification when changing emails is basically not working at all.. 🐛

Also, I want to force verification (no matter whether the current one was verified or not).

Can this be escalated @himself65?

@wottpal commented on GitHub (Aug 28, 2025): Same issue here (not even the client routes are working). Email verification when changing emails is basically not working at all.. 🐛 Also, I want to force verification (no matter whether the current one was verified or not). Can this be escalated @himself65?
Author
Owner

@himself65 commented on GitHub (Aug 28, 2025):

Thanks for ping me, I will take this into priority

@himself65 commented on GitHub (Aug 28, 2025): Thanks for ping me, I will take this into priority
Author
Owner

@dosubot[bot] commented on GitHub (Dec 4, 2025):

Hi, @daviduzondu. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You reported that the server-side auth.api.changeEmail updates a verified user's email immediately without waiting for verification, unlike the client-side flow.
  • It was confirmed this behavior is unintended and may be due to bugs or misconfigurations.
  • Other users have noted that sendChangeEmailVerification often doesn't trigger, with one user finding that disabling session.cookieCache helped.
  • There have been multiple reports of email verification failures, and the issue was escalated and prioritized by a maintainer.
  • The issue remains unresolved with no recent updates.

Next Steps:

  • Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open.
  • Otherwise, I will automatically close this issue in 7 days.

Thanks for your understanding and contribution!

@dosubot[bot] commented on GitHub (Dec 4, 2025): Hi, @daviduzondu. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You reported that the server-side `auth.api.changeEmail` updates a verified user's email immediately without waiting for verification, unlike the client-side flow. - It was confirmed this behavior is unintended and may be due to bugs or misconfigurations. - Other users have noted that `sendChangeEmailVerification` often doesn't trigger, with one user finding that disabling `session.cookieCache` helped. - There have been multiple reports of email verification failures, and the issue was escalated and prioritized by a maintainer. - The issue remains unresolved with no recent updates. **Next Steps:** - Please let me know if this issue is still relevant with the latest version of better-auth by commenting here to keep the discussion open. - Otherwise, I will automatically close this issue in 7 days. Thanks for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1512