[GH-ISSUE #8043] emailOtp + changeEmail: sendVerificationOTP silently not called when changing a verified user's email #28302

Open
opened 2026-04-17 19:44:01 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @hartbit on GitHub (Feb 18, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8043

Bug Description

When the emailOtp plugin is enabled with overrideDefaultEmailVerification: true and user.changeEmail.enabled: true, changing a verified user's email silently fails to send the verification OTP for the new email. The /change-email endpoint returns { status: true } but sendVerificationOTP is never invoked.

Root Cause

The emailOtp plugin overrides sendVerificationEmail to call sendVerificationOTPAction internally. When a verified user changes their email, the change-email flow calls the overridden sendVerificationEmail with the new email. Inside sendVerificationOTPAction, findUserByEmail(newEmail) returns null because the user still has their old email. Since the type is "email-verification" (not "sign-in"), the code silently deletes the OTP and returns { success: true } without ever invoking the sendVerificationOTP callback.

Relevant code in send-verification-otp route:

if (!await ctx.context.internalAdapter.findUserByEmail(email))
  if (ctx.body.type === "sign-in" && !opts.disableSignUp) {
    // sign-in: allow creating new user
  } else {
    // ← HERE: silently deletes OTP and returns success without sending
    await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${ctx.body.type}-otp-${email}`);
    return ctx.json({ success: true });
  }

Steps to Reproduce

  1. Configure betterAuth with emailOtp({ overrideDefaultEmailVerification: true }) and user.changeEmail.enabled: true
  2. Sign up a user and verify their email via OTP
  3. Call /change-email with a new email
  4. Observe that sendVerificationOTP is never called for the new email

Reproduction Repository

https://github.com/hartbit/repro-emailotp-changeemail

git clone https://github.com/hartbit/repro-emailotp-changeemail.git
cd repro-emailotp-changeemail
npm install
npm test

The test expects sendVerificationOTP to be called with email: "user@new.com" after the change-email request, but it is never called.

Expected Behavior

sendVerificationOTP should be called with the new email address so the user can verify ownership of the new email.

Actual Behavior

sendVerificationOTP is never called. The change-email endpoint returns success but no verification OTP is sent for the new email.

Environment

  • better-auth: 1.4.18
  • Node.js: v22
  • OS: macOS
Originally created by @hartbit on GitHub (Feb 18, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8043 ## Bug Description When the `emailOtp` plugin is enabled with `overrideDefaultEmailVerification: true` and `user.changeEmail.enabled: true`, changing a verified user's email silently fails to send the verification OTP for the new email. The `/change-email` endpoint returns `{ status: true }` but `sendVerificationOTP` is never invoked. ## Root Cause The `emailOtp` plugin overrides `sendVerificationEmail` to call `sendVerificationOTPAction` internally. When a verified user changes their email, the change-email flow calls the overridden `sendVerificationEmail` with the **new** email. Inside `sendVerificationOTPAction`, `findUserByEmail(newEmail)` returns `null` because the user still has their **old** email. Since the type is `"email-verification"` (not `"sign-in"`), the code silently deletes the OTP and returns `{ success: true }` without ever invoking the `sendVerificationOTP` callback. Relevant code in `send-verification-otp` route: ```js if (!await ctx.context.internalAdapter.findUserByEmail(email)) if (ctx.body.type === "sign-in" && !opts.disableSignUp) { // sign-in: allow creating new user } else { // ← HERE: silently deletes OTP and returns success without sending await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${ctx.body.type}-otp-${email}`); return ctx.json({ success: true }); } ``` ## Steps to Reproduce 1. Configure `betterAuth` with `emailOtp({ overrideDefaultEmailVerification: true })` and `user.changeEmail.enabled: true` 2. Sign up a user and verify their email via OTP 3. Call `/change-email` with a new email 4. Observe that `sendVerificationOTP` is never called for the new email ## Reproduction Repository https://github.com/hartbit/repro-emailotp-changeemail ```bash git clone https://github.com/hartbit/repro-emailotp-changeemail.git cd repro-emailotp-changeemail npm install npm test ``` The test expects `sendVerificationOTP` to be called with `email: "user@new.com"` after the change-email request, but it is never called. ## Expected Behavior `sendVerificationOTP` should be called with the new email address so the user can verify ownership of the new email. ## Actual Behavior `sendVerificationOTP` is never called. The change-email endpoint returns success but no verification OTP is sent for the new email. ## Environment - better-auth: 1.4.18 - Node.js: v22 - OS: macOS
GiteaMirror added the credentialsbug labels 2026-04-17 19:44:01 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Feb 18, 2026):

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

Your root cause analysis is correct. This is a known architectural limitation documented in issue #879 and discussion #5540.

The problem is that the emailOtp plugin's sendVerificationOTPAction uses findUserByEmail(newEmail) which returns null because the user's email hasn't been updated yet. The silent failure for non-existent users is intentional security design to prevent user enumeration attacks, but it creates this bug for the change-email flow.

Workaround: As mentioned in discussion #5540, you'll need to implement a custom flow:

  1. Manually generate and store an OTP
  2. Send it via a custom sendChangeEmailConfirmation handler
  3. Create a custom endpoint to verify the OTP before updating the email

Note: There's also a potential regression in v1.4.0+ that may affect sendVerificationOTP callbacks, which could compound this issue.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3920829571 --> @dosubot[bot] commented on GitHub (Feb 18, 2026): <!-- Greeting --> Hi @hartbit! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> Your root cause analysis is correct. This is a known architectural limitation documented in [issue #879](https://github.com/better-auth/better-auth/issues/879) and [discussion #5540](https://github.com/better-auth/better-auth/discussions/5540#discussioncomment-14772207). The problem is that the [emailOtp plugin's `sendVerificationOTPAction`](https://github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts#L121-L133) uses `findUserByEmail(newEmail)` which returns null because the user's email hasn't been updated yet. The [silent failure for non-existent users](https://github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts#L126-L131) is intentional security design to prevent user enumeration attacks, but it creates this bug for the change-email flow. **Workaround**: As mentioned in [discussion #5540](https://github.com/better-auth/better-auth/discussions/5540), you'll need to implement a custom flow: 1. Manually generate and store an OTP 2. Send it via a custom `sendChangeEmailConfirmation` handler 3. Create a custom endpoint to verify the OTP before updating the email Note: There's also a [potential regression in v1.4.0+](https://github.com/better-auth/better-auth/issues/6354) that may affect `sendVerificationOTP` callbacks, which could compound this issue. <!-- 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/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/5a6372f8-fb64-46d4-b523-b9c245ee533c?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Your%20root%20cause%20analysis%20is%20correct.%20This%20is%20a%20known%20architectural%20limitation%20documented%20in%20%5Bissue%20%23879%5D%28https%3A//github.com/better-auth/better-auth/issues/879%29%20and%20%5Bdiscussion%20%235540%5D%28https%3A//github.com/better-auth/better-auth/discussions/5540%23discussioncomment-14772207%29.%0A%0AThe%20problem%20is%20that%20the%20%5BemailOtp%20plugin%27s%20%60sendVerificationOTPAction%60%5D%28https%3A//github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts%23L121-L133%29%20uses%20%60findUserByEmail%28newEmail%29%60%20which%20returns%20null%20because%20the%20user%27s%20email%20hasn%27t%20been%20updated%20yet.%20The%20%5Bsilent%20failure%20for%20non-existent%20users%5D%28https%3A//github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts%23L126-L131%29%20is%20intentional%20security%20design%20to%20prevent%20user%20enumeration%20attacks%2C%20but%20it%20creates%20this%20bug%20for%20the%20change-email%20flow.%0A%0A%2A%2AWorkaround%2A%2A%3A%20As%20mentioned%20in%20%5Bdiscussion%20%235540%5D%28https%3A//github.com/better-auth/better-auth/discussions/5540%29%2C%20you%27ll%20need%20to%20implement%20a%20custom%20flow%3A%0A1.%20Manually%20generate%20and%20store%20an%20OTP%0A2.%20Send%20it%20via%20a%20custom%20%60sendChangeEmailConfirmation%60%20handler%0A3.%20Create%20a%20custom%20endpoint%20to%20verify%20the%20OTP%20before%20updating%20the%20email%0A%0ANote%3A%20There%27s%20also%20a%20%5Bpotential%20regression%20in%20v1.4.0%2B%5D%28https%3A//github.com/better-auth/better-auth/issues/6354%29%20that%20may%20affect%20%60sendVerificationOTP%60%20callbacks%2C%20which%20could%20compound%20this%20issue.)&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/8043)
Author
Owner

@hartbit commented on GitHub (Feb 18, 2026):

@dosu The options.user.changeEmail.sendChangeEmailVerification function mentioned in #879 mentions the old sendChangeEmailVerification of Better Auth 1.3, which is the equivalent of Better Auth 1.4's options.user.changeEmail.sendChangeEmailConfirmation. I'm not talking about that: in my repro, sendChangeEmailConfirmation isn't even implemented. I'm talking about the call to /change-email silently failing when the emailOtp is enabled. This is a regression since 1.4.

<!-- gh-comment-id:3920883484 --> @hartbit commented on GitHub (Feb 18, 2026): @dosu The `options.user.changeEmail.sendChangeEmailVerification` function mentioned in [#879](https://github.com/better-auth/better-auth/issues/879) mentions the old `sendChangeEmailVerification` of Better Auth 1.3, which is the equivalent of Better Auth 1.4's `options.user.changeEmail.sendChangeEmailConfirmation`. I'm not talking about that: in my repro, `sendChangeEmailConfirmation` isn't even implemented. I'm talking about the call to `/change-email` silently failing when the `emailOtp` is enabled. This is a regression since 1.4.
Author
Owner

@dosubot[bot] commented on GitHub (Feb 18, 2026):

You're right, I apologize for the confusion. This is indeed a distinct issue from #879.

The problem you're describing is a fundamental incompatibility between overrideDefaultEmailVerification: true and the change-email flow. The emailOtp plugin's sendVerificationOTPAction checks if a user exists with the email before sending the OTP. Since the new email doesn't exist in the database yet (the user still has their old email), it silently returns { success: true } without calling sendVerificationOTP.

This limitation was explicitly identified in PR #7968, which introduced dedicated change-email OTP endpoints as the solution:

  • POST /email-otp/request-email-change
  • POST /email-otp/change-email

Additionally, since v1.4.7, errors in sendVerificationOTP no longer propagate to clients due to background task runner changes, which makes this issue appear even more silent.

The recommended approach is to use the dedicated change-email OTP endpoints from PR #7968 if your version includes them, rather than relying on overrideDefaultEmailVerification for change-email flows.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3920921268 --> @dosubot[bot] commented on GitHub (Feb 18, 2026): <!-- Answer --> You're right, I apologize for the confusion. This is indeed a distinct issue from #879. The problem you're describing is a **fundamental incompatibility** between `overrideDefaultEmailVerification: true` and the change-email flow. The [emailOtp plugin's `sendVerificationOTPAction` checks if a user exists with the email](https://github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts#L121-L132) before sending the OTP. Since the new email doesn't exist in the database yet (the user still has their old email), it silently returns `{ success: true }` without calling `sendVerificationOTP`. This limitation was explicitly identified in [PR #7968](https://github.com/better-auth/better-auth/pull/7968), which introduced dedicated change-email OTP endpoints as the solution: - `POST /email-otp/request-email-change` - `POST /email-otp/change-email` Additionally, since v1.4.7, [errors in `sendVerificationOTP` no longer propagate to clients](https://github.com/better-auth/better-auth/issues/7348) due to background task runner changes, which makes this issue appear even more silent. The recommended approach is to use the dedicated change-email OTP endpoints from PR #7968 if your version includes them, rather than relying on `overrideDefaultEmailVerification` for change-email flows. <!-- 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/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/f0145637-4bc6-4e14-91db-221fd3849b1f?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20right%2C%20I%20apologize%20for%20the%20confusion.%20This%20is%20indeed%20a%20distinct%20issue%20from%20%23879.%0A%0AThe%20problem%20you%27re%20describing%20is%20a%20%2A%2Afundamental%20incompatibility%2A%2A%20between%20%60overrideDefaultEmailVerification%3A%20true%60%20and%20the%20change-email%20flow.%20The%20%5BemailOtp%20plugin%27s%20%60sendVerificationOTPAction%60%20checks%20if%20a%20user%20exists%20with%20the%20email%5D%28https%3A//github.com/better-auth/better-auth/blob/86fc96a3d4d680031e54dce1c92b6e12be72f57c/packages/better-auth/src/plugins/email-otp/routes.ts%23L121-L132%29%20before%20sending%20the%20OTP.%20Since%20the%20new%20email%20doesn%27t%20exist%20in%20the%20database%20yet%20%28the%20user%20still%20has%20their%20old%20email%29%2C%20it%20silently%20returns%20%60%7B%20success%3A%20true%20%7D%60%20without%20calling%20%60sendVerificationOTP%60.%0A%0AThis%20limitation%20was%20explicitly%20identified%20in%20%5BPR%20%237968%5D%28https%3A//github.com/better-auth/better-auth/pull/7968%29%2C%20which%20introduced%20dedicated%20change-email%20OTP%20endpoints%20as%20the%20solution%3A%0A-%20%60POST%20/email-otp/request-email-change%60%0A-%20%60POST%20/email-otp/change-email%60%0A%0AAdditionally%2C%20since%20v1.4.7%2C%20%5Berrors%20in%20%60sendVerificationOTP%60%20no%20longer%20propagate%20to%20clients%5D%28https%3A//github.com/better-auth/better-auth/issues/7348%29%20due%20to%20background%20task%20runner%20changes%2C%20which%20makes%20this%20issue%20appear%20even%20more%20silent.%0A%0AThe%20recommended%20approach%20is%20to%20use%20the%20dedicated%20change-email%20OTP%20endpoints%20from%20PR%20%237968%20if%20your%20version%20includes%20them%2C%20rather%20than%20relying%20on%20%60overrideDefaultEmailVerification%60%20for%20change-email%20flows.)&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/8043)
Author
Owner

@hartbit commented on GitHub (Feb 18, 2026):

Indead, PR #7968 is implementing what we are missing.

<!-- gh-comment-id:3920949261 --> @hartbit commented on GitHub (Feb 18, 2026): Indead, [PR #7968](https://github.com/better-auth/better-auth/pull/7968) is implementing what we are missing.
Author
Owner

@gustavovalverde commented on GitHub (Apr 14, 2026):

Related in the same area (not duplicates): #3742, #7002.

<!-- gh-comment-id:4244779673 --> @gustavovalverde commented on GitHub (Apr 14, 2026): Related in the same area (not duplicates): #3742, #7002.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28302