Skip to content

2FA re-enrollment kills the confirmed secret before the new one is confirmed — the availability half #10700 could not reach #11035

Closed

Description

@os-warren

Carved out of #10700, whose integrity half landed in PR #10994. This is the half that PR proved cannot be reached by any change to the verified flag, so it needs its own tracking artifact rather than living as an unclosed bullet on a card whose PR has merged.

Filed unassigned and ungraded — domain:* and type are triage's to mint.

The property that is still false

The old secret goes dead only when the new one is live and confirmed.

POST /api/v1/auth/two-factor/enable rewrites the account's single sys_two_factor row unconditionally, and user_id is declared unique — so there is nowhere to hold an unconfirmed secret. The previously confirmed secret stops working when the call returns. That was true before #10994 and is true after it.

What #10994 did and did not change

✅ Integrity is fully restored. verified now describes the secret stored beside it at every point in the flow; the vendor's existing sign-in gate (TOTP_NOT_ENABLED before any lockout bookkeeping) does the rest, with no second owner of the decision.

✅ The discovery point moved. A mis-scan is now found immediately, at the confirmation step, with a live session in hand — instead of at the next sign-in with none.

✅ The floor across the window is pinned. The backup codes the same enable response issues still complete a sign-in.

❌ The strict property above. Unreachable by a flag.

The four routes, and why none was takeable by the dispatched seat

The seat declined to pick, and its reasoning is why this card exists rather than a silent choice:

that leaves A as the only option I could take unilaterally, which makes "A" a statement about my permissions rather than a measurement

option measured cost
A accept the current position as the end state strictly better than before on integrity, neutral-to-better on availability. The strict property stays false. This is what shipped
B stage the unconfirmed secret in better-auth's verification table, promote it on verify widens what /two-factor/verify-totp accepts — a code derived from a secret that is not on the row — and reimplements vendor crypto / lockout / session-rotation that drifts on every better-auth bump
C add pending-secret columns to sys_two_factor verified by content: that object is managedBy: 'better-auth' (sys-two-factor.object.ts:19) and lock: 'full' (:24). Persisted state on a vendor-managed locked table is a design decision on a published auth table, plus the schema-parity surface
D refuse enable while already enabled; rotation goes through the published disable → enable pair no new state, no new surface — the cheapest route to the strict property. ⚠️ But it removes a currently-working call from a published endpoint, and leaves 2FA fully off in the gap between the two calls. That gap is its own exposure, not a free win

Recommendation, offered as input: if the strict property is wanted, D is cheapest and least drift-prone — but its disable→enable window should be weighed rather than waved past, because it trades a confirmation gap for an unprotected one. If it is not wanted, this card closes as "A is the end state" and the reasoning is on the record either way.

Not a duplicate of #10681

#10681 (sys_user.generate_backup_codes re-provisioning on an already-enabled account) is the same family but a different mechanism, and #10994's seat measured the difference rather than assuming it:

  • the vendor's /two-factor/generate-backup-codes does not consult verified at all — it gates only on user.twoFactorEnabled (backup-codes/index.mjs: if (!user.twoFactorEnabled) throw TWO_FACTOR_NOT_ENABLED). A verified-shaped fix does not transfer to it. Its lockout shape is the silent replacement of live codes, not a stale flag.
  • /two-factor/enable also regenerates and rewrites backupCodes on the same row in the same statement as the secret, and returns them. So the two surfaces overlap on one row, and a fix to either must not assume it is the only writer.

That overlap is also why the availability floor pinned in #10994 holds: the codes the enable response issues are the stored ones.

Recorded here so it is not re-derived

sys_two_factor.verified declares defaultValue: true — a security-relevant flag defaulting to the permissive value. Checked rather than assumed: the only writer of that table in this tree is better-auth, which always passes verified explicitly (positive control: vendor create/update sites exist; zero non-vendor writers under packages/), and the declaration mirrors better-auth's own schema default, so changing it would diverge from the vendor and move the parity gate. lock: 'full' makes a second writer unlikely.

Latent, not live — no defect to fix. It is written down so the next reader of this surface finds it. ⚠️ It is the second time in one session that a declared security property turned out to be defended by an absence or a convention rather than by a declaration — #10702's sys_file columns are the other. A third would be a pattern worth its own card.

⛔ Auth/authz disclosure carve-out applies. Mechanism and verdict only; the withheld reproduction lives in QA session #10663.

Refs

#10700 (parent) · PR #10994 (the integrity half) · #10681 (same family, different mechanism) · QA #10663 · #10702 (the other absence-defended property)

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 22, 2026
  2. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Triage: lands on the auth enrollment flow (plugin-auth / vendor-managed sys_two_factor) ⇒ domain:services, filed into the decision inbox (needs-user-decision) — this sits on the manual floor twice over: security/auth-flow boundary, and option D removes a currently-working call from a published endpoint. B and C are already measured out by the filing seat (vendor crypto drift; persisted state on a managedBy: better-auth + lock: full table) and are listed below only for the record.

    [facets-block]

    • 实际业务需求:用户换手机重录 2FA 是真实场景;fix(plugin-auth): 2FA re-enrollment must not inherit verified from the previous enrollment #10994 之后备份码兜底已钉住(重录失败可用备份码登录),严格性质带来的增益只是「旧验证器在新的确认完成前仍可用」——属边际可用性改进,不是当前有工单拉动的缺口。
    • 项目长远合理性:B/C 都要在 vendor 管理面上另造持久态,随 better-auth 每次升级漂移;A 零新面;D 不加新态、复用已发布 disable→enable 组合,但以「换绑期间 2FA 全关」为代价——把确认缺口换成无保护缺口,不是免费的严格性。
    • 防 AI 写代码犯错:A/D 都不新增半声明面;B 扩大 /two-factor/verify-totp 的接受集(接受不在行上的密钥导出的码),正是要防的形状。
    • 创业阶段不扩散需求:严格性质今天无用户拉动(QA 会话发现,非事故/工单);A 最收敛,D 次之。

    一句话问题:2FA 重录时旧密钥立即失效(新密钥未确认前),要不要为「旧密钥只在新密钥确认后才死」这条严格性质付费?

    选项 客户可感知代价
    A 接受现状为终态,关卡 重录扫码失败的用户在窗口内只能用备份码登录(该兜底已被测试钉住)
    D 已启用时拒绝 enable,换绑走 disable→enable 换绑期间账号短暂无 2FA 保护;一个当前可用的调用变为报错(已发布面收窄)

    推荐 A(置信度中高):四轴同向——兜底已钉、无新面、无拉动;D 的无保护窗口是真实安全代价而非纯收益。本分析看不见什么:真实部署里用户重录 2FA 的频率与失败率(无工单数据);若维护者判断「旧验证器不中断」对目标客户(企业管理员)重要,则 D 是唯一不造 vendor 漂移的路。裁决格式:回「A」或「D」即可;A ⇒ 本卡以「A 为终态」关闭,D ⇒ 晋级 pm:queue 派 services 席。

    裁后执行段:A ⇒ close as completed with the ruling quoted;D ⇒ relabel pm:queue,dispatch order 须带「disable→enable 窗口暴露已被裁决接受」句 + 拒收用例(enable while enabled ⇒ loud error code)。另记:卡内「verified defaultValue: true 属 absence-defended 安全性质」的备忘已读——同类第三例出现时按卡内预告立 pattern 卡,本轮不立。


    Generated by Claude Code

  3. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 22, 2026
  4. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded — A: the current position is the end state

    Provenance: maintainer, 2026-08-22, live session with the triage seat (session_01UqPcqW2u2NJbSdrMddhkuf) — the 18-item decision-inbox digest was presented with per-card four-axis recommendations and accepted verbatim: 「同意所有」 (item 1 = A).

    Effect: the strict property ("the old secret dies only when the new one is confirmed") is not purchased. What shipped with PR #10994 — integrity restored, discovery moved to the confirmation step, backup-code floor pinned — is the accepted end state; options B/C stay rejected on the recorded vendor-drift/locked-table measurements, D's unprotected-window cost is not paid. Closed as completed on that ruling. The latent defaultValue: true note stays on record here; a third absence-defended security property triggers the pattern card per the body's own clause.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedsecurity

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions