Found while binding the auth.* return types for #14313 (not fixed there: that card narrows published return types and does not touch wire bytes).
Reproducible defect
When a SIGNED-IN user confirms a new TOTP factor (POST /api/v1/auth/two-factor/verify-totp, the enrolment lane), better-auth's handler writes twoFactorEnabled: true, rotates the session, and only then calls the valid(ctx) closure that verifyTwoFactor() built at entry — which still holds the PRE-rotation session and serialises parseUserOutput(session.user) from it (better-auth/dist/plugins/two-factor/verify-two-factor.mjs, the signed-in branch). So the 200 body reports the user with twoFactorEnabled: false although the flag has just flipped server-side.
plugin-auth already knows this closure is stale: packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts (#10701) repairs the token member of that same body from the response's own Set-Cookie. It leaves user as the vendor echoed it.
Measured on 2026-09-07, twice — the real AuthManager (better-auth 1.7.2) on the in-memory engine, and again on a real SqlDriver (better-sqlite3) driven through the real ObjectStackClient:
POST /two-factor/enable {password} -> 200 { method: "totp", totpURI, backupCodes }
POST /two-factor/verify-totp {code} -> 200 { token: "(live rotated token)", user: { …, "twoFactorEnabled": false, … } } ← stale
POST /two-factor/verify-backup-code {code} -> 200 { token, user: { …, "twoFactorEnabled": true, … } } ← the next read shows the flip
(verify-backup-code on the signed-in lane does not rotate and echoes the live row, which is how the stale value is visible without a second request.)
Why it matters
The Account portal's enrolment flow reads this body to render "2FA is on"; a client that trusts user.twoFactorEnabled here renders the factor as still OFF right after the user enabled it, and a bearer client that caches the echoed user carries the wrong flag until its next get-session.
Expected
On the rotating 2FA verify routes, when the response staged a session cookie whose token differs from the one echoed (the exact condition two-factor-rotated-token-echo.ts already keys on), the echoed user should be the post-flip row — re-read from the adapter, or at minimum twoFactorEnabled set from the write the handler just made — so the body and the row agree.
Where
packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts — the after-hook that repairs token on ROTATING_TWO_FACTOR_VERIFY_PATHS.
- Vendor:
better-auth/dist/plugins/two-factor/verify-two-factor.mjs (closure over the entry-time session) and two-factor/totp/index.mjs (rotation before valid(ctx)).
Dedup: open domain:cli, domain:services and domain:runtime cards listed via REST and grepped for twoFactorEnabled, verify-totp / verifyTotp and bearer/rotation — zero hits, with the control terms exported-any-returns and better-auth answering.
Found while binding the
auth.*return types for #14313 (not fixed there: that card narrows published return types and does not touch wire bytes).Reproducible defect
When a SIGNED-IN user confirms a new TOTP factor (
POST /api/v1/auth/two-factor/verify-totp, the enrolment lane), better-auth's handler writestwoFactorEnabled: true, rotates the session, and only then calls thevalid(ctx)closure thatverifyTwoFactor()built at entry — which still holds the PRE-rotation session and serialisesparseUserOutput(session.user)from it (better-auth/dist/plugins/two-factor/verify-two-factor.mjs, the signed-in branch). So the 200 body reports the user withtwoFactorEnabled: falsealthough the flag has just flipped server-side.plugin-auth already knows this closure is stale:
packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts(#10701) repairs thetokenmember of that same body from the response's ownSet-Cookie. It leavesuseras the vendor echoed it.Measured on 2026-09-07, twice — the real
AuthManager(better-auth 1.7.2) on the in-memory engine, and again on a realSqlDriver(better-sqlite3) driven through the realObjectStackClient:(
verify-backup-codeon the signed-in lane does not rotate and echoes the live row, which is how the stale value is visible without a second request.)Why it matters
The Account portal's enrolment flow reads this body to render "2FA is on"; a client that trusts
user.twoFactorEnabledhere renders the factor as still OFF right after the user enabled it, and a bearer client that caches the echoed user carries the wrong flag until its nextget-session.Expected
On the rotating 2FA verify routes, when the response staged a session cookie whose token differs from the one echoed (the exact condition
two-factor-rotated-token-echo.tsalready keys on), the echoedusershould be the post-flip row — re-read from the adapter, or at minimumtwoFactorEnabledset from the write the handler just made — so the body and the row agree.Where
packages/plugins/plugin-auth/src/two-factor-rotated-token-echo.ts— the after-hook that repairstokenonROTATING_TWO_FACTOR_VERIFY_PATHS.better-auth/dist/plugins/two-factor/verify-two-factor.mjs(closure over the entry-time session) andtwo-factor/totp/index.mjs(rotation beforevalid(ctx)).Dedup: open
domain:cli,domain:servicesanddomain:runtimecards listed via REST and grepped fortwoFactorEnabled,verify-totp/verifyTotpand bearer/rotation — zero hits, with the control termsexported-any-returnsandbetter-authanswering.