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

Open
opened 2026-04-13 10:34:02 -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 (5)

  • 6ffec49 feat: native pendingEmail, Verification table flow, cancel endpoint
  • 668ae29 fix: prevent pendingEmail field from being overridden by additionalFields
  • 82fad11 fix: use userId instead of oldEmail for email change update
  • e344253 fix: correct OpenAPI metadata for verifyEmailChange (302 redirect, not 200 JSON)
  • e2082f8 fix: rollback pendingEmail and verification on email send failure

📊 Changes

11 files changed (+536 additions, -460 deletions)

View changed files

📝 docs/content/docs/concepts/users-accounts.mdx (+72 -38)
📝 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/phone-number/phone-number.test.ts (+2 -1)
📝 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 (5) - [`6ffec49`](https://github.com/better-auth/better-auth/commit/6ffec49b96572f3c3edde222559263f973311792) feat: native pendingEmail, Verification table flow, cancel endpoint - [`668ae29`](https://github.com/better-auth/better-auth/commit/668ae296126c6537375566706be01dba0de8db38) fix: prevent pendingEmail field from being overridden by additionalFields - [`82fad11`](https://github.com/better-auth/better-auth/commit/82fad11b75fee5e6f9fb27614802c69b4d4e6c1d) fix: use userId instead of oldEmail for email change update - [`e344253`](https://github.com/better-auth/better-auth/commit/e344253751571c9389107a86c7f0ed9a4fdba142) fix: correct OpenAPI metadata for verifyEmailChange (302 redirect, not 200 JSON) - [`e2082f8`](https://github.com/better-auth/better-auth/commit/e2082f8c3e499054f71447e84973e010085789fb) fix: rollback pendingEmail and verification on email send failure ### 📊 Changes **11 files changed** (+536 additions, -460 deletions) <details> <summary>View changed files</summary> 📝 `docs/content/docs/concepts/users-accounts.mdx` (+72 -38) 📝 `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/phone-number/phone-number.test.ts` (+2 -1) 📝 `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-13 10:34:02 -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#16542