[PR #8916] feat: native change-email flow via Verification table #25196

Open
opened 2026-04-15 22:45:37 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/8916
Author: @nphlp
Created: 4/2/2026
Status: 🔄 Open

Base: nextHead: feat/change-email-native


📝 Commits (7)

  • ae227e8 feat: native pendingEmail, Verification table flow, cancel endpoint
  • 449dbb4 fix: prevent pendingEmail field from being overridden by additionalFields
  • f02235b fix: use userId instead of oldEmail for email change update
  • ff92470 fix: correct OpenAPI metadata for verifyEmailChange (302 redirect, not 200 JSON)
  • f37c101 fix: rollback pendingEmail and verification on email send failure
  • 789991d chore: add changeset
  • 51915f6 fix(tests): adapt phone-number and open-api tests to new change-email flow

📊 Changes

13 files changed (+885 additions, -460 deletions)

View changed files

.changeset/native-change-email-flow.md (+58 -0)
📝 docs/content/docs/concepts/users-accounts.mdx (+73 -37)
📝 packages/better-auth/src/api/index.ts (+4 -0)
📝 packages/better-auth/src/api/routes/email-verification.test.ts (+61 -78)
📝 packages/better-auth/src/api/routes/email-verification.ts (+0 -151)
📝 packages/better-auth/src/api/routes/update-user.test.ts (+113 -70)
📝 packages/better-auth/src/api/routes/update-user.ts (+238 -112)
📝 packages/better-auth/src/client/config.ts (+3 -1)
📝 packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap (+279 -0)
📝 packages/better-auth/src/plugins/phone-number/phone-number.test.ts (+13 -2)
📝 packages/core/src/db/get-tables.ts (+12 -0)
📝 packages/core/src/types/init-options.ts (+30 -7)
📝 packages/telemetry/src/detectors/detect-auth-config.ts (+1 -2)

📄 Description

Summary

The current change-email flow relies on signed JWT tokens: the token contains the newEmail and is verified through the generic /verify-email endpoint. This design has several issues:

  1. No visible pendingEmail — no way to show the pending email in the UI
  2. No cancellation — once the token is sent, the user cannot cancel
  3. No callbacks — no way to react to flow steps (request, completion, cancellation)
  4. JWT token in URL — the token contains sensitive data, and the verification flow is mixed with regular email verification

This PR refactors change-email to use the Verification table (already used for password reset and OTP), with a native pendingEmail field on the User model.

Changes

New flow:

  1. POST /change-email → stores pendingEmail + creates a Verification entry → sends email via sendVerificationEmail
  2. User clicks the link → GET /verify-email-change/:userId/:token → verifies token, updates email, deletes Verification entry, creates new session
  3. (Optional) POST /cancel-email-change → deletes Verification entry + clears pendingEmail

pendingEmail field:

  • Conditionally added to the user table when changeEmail.enabled: true
  • Read-only field (input: false), not directly modifiable via API

Dedicated sending callback:

  • sendVerificationEmail — in user.changeEmail, replaces the dependency on emailVerification.sendVerificationEmail. Allows a distinct email template for email change vs account verification.

Lifecycle callbacks:

  • onChangeEmailRequested({ user, newEmail }, request) — when the change is requested
  • onChangeEmailCompleted({ user, oldEmail, newEmail }, request) — when the change is verified and applied
  • onChangeEmailCancelled({ user }, request) — when the change is cancelled

Options:

  • revokeOtherSessions: true — revokes all other sessions after email change

Use cases

  • Display pending email: show pendingEmail in the user profile with a "Cancel" button
  • Security notification: send a confirmation email to the old address when the change is completed (onChangeEmailCompleted)
  • Audit: log email change requests, cancellations, and completions
  • Session revocation: force re-login on all devices after an email change (revokeOtherSessions)

Technical decisions

  • Verification table over JWT: token is stored in the database, enabling cancellation, per-user uniqueness (change-email:${userId}), and deletion after use (one-time use).
  • Removal of old flow from email-verification.ts: the 151-line JWT flow is removed. The new flow lives entirely in update-user.ts.
  • Email enumeration protection: when the target email already exists, the endpoint simulates token generation and returns { status: true } (no information leaks).
  • Client config: /cancel-email-change is registered as POST in pluginPathMethods, and atomListeners are updated to refresh client state.

Tests (23 tests)

File Coverage
update-user.test.ts pendingEmail stored, verification via Verification table, revoke other sessions, cancellation, reject after cancel, callbacks, anti-enumeration
email-verification.test.ts change email via Verification table, onChangeEmailCompleted, emailVerified propagated to all sessions

Documentation

  • users-accounts.mdx: "Change Email" section fully rewritten — setup, flow, cancellation, lifecycle callbacks, revokeOtherSessions, pendingEmail schema, server + client usage

Note

This could also be implemented as a plugin for consistency with 2FA/passkey — open to feedback.

Changed files (11 files, +536 −458)

  • packages/core/src/types/init-options.ts — onChangeEmail*, changeEmail config types + dedicated sendVerificationEmail
  • packages/core/src/db/get-tables.ts — conditional pendingEmail field
  • packages/better-auth/src/api/routes/update-user.ts — changeEmail, cancelEmailChange, verifyEmailChange
  • packages/better-auth/src/api/routes/email-verification.ts — removed old JWT flow
  • packages/better-auth/src/api/index.ts — register new endpoints
  • packages/better-auth/src/client/config.ts — pluginPathMethods + atomListeners
  • packages/telemetry/src/detectors/detect-auth-config.ts — changeEmail options detection
  • Tests + Docs

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/8916 **Author:** [@nphlp](https://github.com/nphlp) **Created:** 4/2/2026 **Status:** 🔄 Open **Base:** `next` ← **Head:** `feat/change-email-native` --- ### 📝 Commits (7) - [`ae227e8`](https://github.com/better-auth/better-auth/commit/ae227e8c6c9d774b58a932acc71d0f9fd9145a03) feat: native pendingEmail, Verification table flow, cancel endpoint - [`449dbb4`](https://github.com/better-auth/better-auth/commit/449dbb46ea4a662afea2e1ed3b9bb57a3c85de18) fix: prevent pendingEmail field from being overridden by additionalFields - [`f02235b`](https://github.com/better-auth/better-auth/commit/f02235b92188f76c72d83919d0db6c40ccdb165a) fix: use userId instead of oldEmail for email change update - [`ff92470`](https://github.com/better-auth/better-auth/commit/ff9247077d615cbdccd34d04dbf772311d693701) fix: correct OpenAPI metadata for verifyEmailChange (302 redirect, not 200 JSON) - [`f37c101`](https://github.com/better-auth/better-auth/commit/f37c1014682cc5dbec2b374cac805cdc5f3e9a0b) fix: rollback pendingEmail and verification on email send failure - [`789991d`](https://github.com/better-auth/better-auth/commit/789991dd7de7103b294655d300507daabf173b3f) chore: add changeset - [`51915f6`](https://github.com/better-auth/better-auth/commit/51915f6ca53d6f62943b4c6843b117e6d4ee3085) fix(tests): adapt phone-number and open-api tests to new change-email flow ### 📊 Changes **13 files changed** (+885 additions, -460 deletions) <details> <summary>View changed files</summary> ➕ `.changeset/native-change-email-flow.md` (+58 -0) 📝 `docs/content/docs/concepts/users-accounts.mdx` (+73 -37) 📝 `packages/better-auth/src/api/index.ts` (+4 -0) 📝 `packages/better-auth/src/api/routes/email-verification.test.ts` (+61 -78) 📝 `packages/better-auth/src/api/routes/email-verification.ts` (+0 -151) 📝 `packages/better-auth/src/api/routes/update-user.test.ts` (+113 -70) 📝 `packages/better-auth/src/api/routes/update-user.ts` (+238 -112) 📝 `packages/better-auth/src/client/config.ts` (+3 -1) 📝 `packages/better-auth/src/plugins/open-api/__snapshots__/open-api.test.ts.snap` (+279 -0) 📝 `packages/better-auth/src/plugins/phone-number/phone-number.test.ts` (+13 -2) 📝 `packages/core/src/db/get-tables.ts` (+12 -0) 📝 `packages/core/src/types/init-options.ts` (+30 -7) 📝 `packages/telemetry/src/detectors/detect-auth-config.ts` (+1 -2) </details> ### 📄 Description ## Summary The current change-email flow relies on signed JWT tokens: the token contains the `newEmail` and is verified through the generic `/verify-email` endpoint. This design has several issues: 1. **No visible `pendingEmail`** — no way to show the pending email in the UI 2. **No cancellation** — once the token is sent, the user cannot cancel 3. **No callbacks** — no way to react to flow steps (request, completion, cancellation) 4. **JWT token in URL** — the token contains sensitive data, and the verification flow is mixed with regular email verification This PR refactors change-email to use the **`Verification` table** (already used for password reset and OTP), with a native `pendingEmail` field on the `User` model. ### Changes **New flow:** 1. `POST /change-email` → stores `pendingEmail` + creates a `Verification` entry → sends email via `sendVerificationEmail` 2. User clicks the link → `GET /verify-email-change/:userId/:token` → verifies token, updates email, deletes Verification entry, creates new session 3. (Optional) `POST /cancel-email-change` → deletes Verification entry + clears `pendingEmail` **`pendingEmail` field:** - Conditionally added to the `user` table when `changeEmail.enabled: true` - Read-only field (`input: false`), not directly modifiable via API **Dedicated sending callback:** - `sendVerificationEmail` — in `user.changeEmail`, replaces the dependency on `emailVerification.sendVerificationEmail`. Allows a distinct email template for email change vs account verification. **Lifecycle callbacks:** - `onChangeEmailRequested({ user, newEmail }, request)` — when the change is requested - `onChangeEmailCompleted({ user, oldEmail, newEmail }, request)` — when the change is verified and applied - `onChangeEmailCancelled({ user }, request)` — when the change is cancelled **Options:** - `revokeOtherSessions: true` — revokes all other sessions after email change ### Use cases - **Display pending email**: show `pendingEmail` in the user profile with a "Cancel" button - **Security notification**: send a confirmation email to the old address when the change is completed (`onChangeEmailCompleted`) - **Audit**: log email change requests, cancellations, and completions - **Session revocation**: force re-login on all devices after an email change (`revokeOtherSessions`) ### Technical decisions - **Verification table** over JWT: token is stored in the database, enabling cancellation, per-user uniqueness (`change-email:${userId}`), and deletion after use (one-time use). - **Removal of old flow** from `email-verification.ts`: the 151-line JWT flow is removed. The new flow lives entirely in `update-user.ts`. - **Email enumeration protection**: when the target email already exists, the endpoint simulates token generation and returns `{ status: true }` (no information leaks). - **Client config**: `/cancel-email-change` is registered as `POST` in `pluginPathMethods`, and `atomListeners` are updated to refresh client state. ### Tests (23 tests) | File | Coverage | |---|---| | `update-user.test.ts` | pendingEmail stored, verification via Verification table, revoke other sessions, cancellation, reject after cancel, callbacks, anti-enumeration | | `email-verification.test.ts` | change email via Verification table, onChangeEmailCompleted, emailVerified propagated to all sessions | ### Documentation - **`users-accounts.mdx`**: "Change Email" section fully rewritten — setup, flow, cancellation, lifecycle callbacks, revokeOtherSessions, pendingEmail schema, server + client usage ### Note > This could also be implemented as a plugin for consistency with 2FA/passkey — open to feedback. ### Changed files (11 files, +536 −458) - `packages/core/src/types/init-options.ts` — onChangeEmail*, changeEmail config types + dedicated `sendVerificationEmail` - `packages/core/src/db/get-tables.ts` — conditional pendingEmail field - `packages/better-auth/src/api/routes/update-user.ts` — changeEmail, cancelEmailChange, verifyEmailChange - `packages/better-auth/src/api/routes/email-verification.ts` — removed old JWT flow - `packages/better-auth/src/api/index.ts` — register new endpoints - `packages/better-auth/src/client/config.ts` — pluginPathMethods + atomListeners - `packages/telemetry/src/detectors/detect-auth-config.ts` — changeEmail options detection - Tests + Docs --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-15 22:45:37 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#25196