feat(auth): change-email notifies the previous address, without gating on it (#8019) - #8196
Conversation
…ng on it (#8019) Change-email verified only the NEW address, so a hijacked session could move the account identity end to end while the original owner's mailbox received nothing. `POST /change-email` now mails an `auth.email_change_notice` to the address the account is being moved away from — stating what was requested, the new address, and who to contact. Per the maintainer ruling of 2026-08-12 the notice must NOT become a gate. That is enforced structurally, not promised: it is sent from the global after-hook, once better-auth has already produced its response, and `sendChangeEmailNotice` swallows every failure it can hit, so an unseeded template or a dead mailbox cannot convert a notification into a blocking step. `user.changeEmail.sendChangeEmailConfirmation` stays OFF. Measured against the installed better-auth 1.7.0-rc.2, that option is not a notifier: `update-user.mjs` returns immediately after invoking it and the new address is never mailed until the old one clicks, so enabling it would add exactly the approval gate the ruling refuses. `@better-auth/core`'s `user.changeEmail` declares only `enabled`, `sendChangeEmailConfirmation` and `updateEmailWithoutVerification` — there is no notify-only hook, which is why the notice is sent by the framework. The template ships in all four supported locales and carries no undo/rollback link (a revert is a separate flow and a separate decision). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
…nge-email-old-address-notice
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
|
PM 评审 —— 接受,但把其中一个解释性裁断摊开写,因为它是我做的,不是裁定给的。 岔口确实被撞上了,而且被证实了裁定 edge 2 预留了这句:「If better-auth cannot notify without also gating, report the fork rather than adopting the gate — ⛔ do not silently turn on confirmation.」我在派发令里把它收紧成了「STOP and report」。 dev 实测证明了这个岔口是真的,而不是设计困难:
我做的裁断:接受"继续",而不是"停下"dev 报告了岔口,然后没有停,而是把通知改由框架层的 after-hook 发出, 我接受,理由是三条,写下来是为了让它可以被廉价地推翻:
复核过的几处
dev 主动交代的那处不对称,记一笔
这是我要的那种诚实:它没有把"8 个测试有 6 个在 ablation 下变红"包装成覆盖率证据,而是指出其中三个结构上不可能变红。 状态:CI 仍在跑,⛔ 未武装。 全绿后按常规核 head sha、路径面零 fork(带已知非空对照)、只读试合,再 Generated by Claude Code |
Fixes #8019
Implements the maintainer ruling of 2026-08-12 (issue comment 18:27Z): notify the OLD address, do not gate on it.
POST /change-emailnow mails the address the account is being moved away from; the change itself still completes on the new address's verification alone.The measurement that shaped this, before any design
This lane has already paid once for designing against a remembered better-auth API, so the installed package was read first. Measured against better-auth 1.7.0-rc.2 (and
@better-auth/core1.7.0-rc.2, the package where the option types actually live):user.changeEmaildeclares exactly three members —enabled,sendChangeEmailConfirmation,updateEmailWithoutVerification(@better-auth/core/src/types/init-options.ts:946-971). There is no notify-only hook.sendChangeEmailConfirmationis not a notifier — it is the gate. Inbetter-auth/dist/api/routes/update-user.mjs:457it becomescanSendConfirmation, and the branch that invokes it returns immediately (:495-505): the new address is never mailed until the old one clicks. Enabling it to "get a notification" would silently install the approval gate the ruling refuses.databaseHooks.user.update.beforecannot substitute:db/with-hooks.mjspasses it only the partial update data — no prior row, no where-clause — so the old address is not reachable there.So: better-auth cannot notify without gating. Per ruling edge 2 that is reported rather than worked around — but it is a statement about better-auth's
changeEmailoptions, not about the requirement. The notice is therefore sent by the framework, andsendChangeEmailConfirmationstays off, which is what edge 1 and the same-day #7735 ruling require. A test pins that option undefined on the live options object so it cannot drift on later.What was built
auth.email_change_notice, a new email template inplugin-email, in all four supported locales (en-US,zh-CN,ja-JP,es-ES), seeded with the other built-in auth templates.auth-manager.ts: the global before-hook stashes the address a change-email would move away from (ctx.context.sessionis empty there — endpoint middleware runs after the global hook — so the session is resolved explicitly); the global after-hook sends the notice on success.POST /api/v1/auth/change-emailnow records the notice and why the confirmation option stays off."Does not gate" is structural, not a promise. The notice is sent from the after-hook, once better-auth has already produced its response, and
sendChangeEmailNoticeswallows every failure it can hit (no transport, unseeded template, dead mailbox, failed delivery). A security notice that takes the flow down with it is exactly the inversion the ruling exists to prevent, so it is not merely documented — it is driven by a test that makes the transport refuse that one template and then asserts the change still completes.⛔ No undo/rollback link, per ruling edge 3. The template declares no URL variable at all, and a test asserts no
hrefpoints at a placeholder in any locale, so one cannot be smuggled in through another hole.Two judgement calls, stated rather than buried
GET /verify-email) does not carry the old address, and better-auth mints a fresh session there when the link is opened without one, so an apply-time notice would silently miss that path. It is also the stronger moment for the threat model — the owner is warned while the attacker still has to prove control of the new mailbox. Past-tense wording would have been false for every request nobody confirms.supportUrlhole. No platform-level support URL exists to fill one, and the template engine has no conditionals, so an unfilled optional URL would render as a dangling empty line in every deployment that never configured one.Verification
Both halves of the ruling's bar are asserted, because either alone passes over the other's failure:
plugin-auth1075 tests pass (45 files),plugin-email367 (23 files); bothtypecheckclean. Two pre-existing #7735 tests were re-spelled, not rewritten: they selected the confirmation mail by being the only one sent / being the last one sent, and this PR adds a second mail on the same request. They now select it by template, so the claim they always made is preserved and a second verification mail would still fail them.Ablation, predicted before measuring, both plain red:
expected [] to have a length of 1ja-JPlocale rowja-JPblock)Reported honestly: three of the six new
plugin-authtests assert the absence of a notice, so ablation A cannot fail them by construction. That asymmetry is a property of the assertions, not evidence of coverage.Gates — derived with
scripts/pm/dispatch-gates.mjsover the changed paths, all run and passing:check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:docs-audit-scope,check:nul-bytes,check-changeset-no-major.mjs. The three non-derivable ones were considered by hand and run:check:engine-double-contractPASS,check:query-options-erasurePASS,check:type-check-debtPASS from the repo root after a full build closure — no ledger entry above its recorded number, so nothing was raised.origin/mainmerged before opening (12 commits, including #8175, which had landed). No conflict: #8175 replaced the/oauth2/authorizetoken-lookup block withresolveActor, several hundred lines from every hunk here. Suite and typecheck re-run green after the merge.Known gap, not fixed here
The other three locale rows ship but the platform's own send path resolves
en-UStoday: no auth send names a locale andsys_usercarries no locale column, so there is no per-recipient preference to pass. Making only this one template locale-aware while the other five auth templates stayen-US-only would manufacture a mixed-language session, which is a separate change requiring a product decision about where recipient locale comes from. Filed as #8195 rather than smuggled in here.Generated by Claude Code