Repository navigation
Description
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. |
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-codesdoes not consultverifiedat all — it gates only onuser.twoFactorEnabled(backup-codes/index.mjs:if (!user.twoFactorEnabled) throw TWO_FACTOR_NOT_ENABLED). Averified-shaped fix does not transfer to it. Its lockout shape is the silent replacement of live codes, not a stale flag. /two-factor/enablealso regenerates and rewritesbackupCodeson 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. 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
huangyiirene commentedon Aug 22, 2026
Triage: lands on the auth enrollment flow (
plugin-auth/ vendor-managedsys_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 amanagedBy: better-auth+lock: fulltable) and are listed below only for the record.[facets-block]
verifiedfrom the previous enrollment #10994 之后备份码兜底已钉住(重录失败可用备份码登录),严格性质带来的增益只是「旧验证器在新的确认完成前仍可用」——属边际可用性改进,不是当前有工单拉动的缺口。/two-factor/verify-totp的接受集(接受不在行上的密钥导出的码),正是要防的形状。一句话问题:2FA 重录时旧密钥立即失效(新密钥未确认前),要不要为「旧密钥只在新密钥确认后才死」这条严格性质付费?
enable,换绑走 disable→enable推荐 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 窗口暴露已被裁决接受」句 + 拒收用例(enablewhile enabled ⇒ loud error code)。另记:卡内「verifieddefaultValue: true 属 absence-defended 安全性质」的备忘已读——同类第三例出现时按卡内预告立 pattern 卡,本轮不立。Generated by Claude Code
huangyiirene commentedon Aug 22, 2026
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: truenote stays on record here; a third absence-defended security property triggers the pattern card per the body's own clause.Generated by Claude Code