Found while binding the auth.* return types for #14313 (not fixed there: that card is scoped to narrowing published return types, and this is a runtime behaviour of the SDK's credential handling).
Reproducible defect
ObjectStackClient authenticates every request with Authorization: Bearer from the token auth.login() stored (this.token). Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in Set-Cookie (and the bearer plugin's set-auth-token response header), and delete the old row:
POST /two-factor/disable — answers { status: true } only; the new token rides the cookie / set-auth-token header.
POST /two-factor/verify-totp on the ENROLMENT lane (a signed-in user confirming a new factor) — answers { token, user } with the live token (plugin-auth's two-factor-rotated-token-echo repairs the vendor's stale echo).
POST /change-password with revokeOtherSessions: true — answers { token, user } with the new token.
The SDK reads none of them: packages/client/src/index.ts has zero reads of the set-auth-token header (measured with grep), and twoFactor.disable, twoFactor.verifyTotp and changePassword all return res.json() without touching this.token. So after any of the three, the SDK keeps presenting the DELETED session's token and its next call answers 401 UNAUTHORIZED.
Measured on 2026-09-07 against the real AuthPlugin mounts over a real Hono app with a real AuthManager (better-auth 1.7.2) on a real SqlDriver (better-sqlite3), driven THROUGH the real ObjectStackClient with only the socket stood in for (fetch: (u, init) => app.request(u, init)):
client.auth.login({ email, password }) -> RESOLVED { token: "hz8l…", user } (SDK stores the token)
client.auth.twoFactor.enable({ password }) -> RESOLVED { totpURI, backupCodes }
client.auth.twoFactor.verifyTotp({ code }) -> RESOLVED { token: "wXqy…", user } (rotated; SDK still holds "hz8l…")
(the probe re-set client.token by hand here to continue)
client.auth.twoFactor.disable({ password }) -> RESOLVED { status: true } (rotated again; nothing echoed)
client.auth.deleteUser({ password }) -> REJECTED 401 UNAUTHORIZED (the very next call)
Through the raw route with a cookie jar the same disable call stages a fresh session_token cookie (one Set-Cookie), so a browser caller is fine; only the bearer path — the SDK's own mode — is signed out.
Expected
The SDK keeps the session it was just handed: store the token echoed by changePassword / verifyTotp when present (the way login() and refreshToken() already do), and read the bearer plugin's set-auth-token response header for the routes that echo no token (twoFactor.disable) — or document that bearer callers must re-login after these calls.
Where
packages/client/src/index.ts — auth.changePassword, auth.twoFactor.verifyTotp, auth.twoFactor.disable, and the fetch wrapper (no set-auth-token read).
- Vendor mechanism:
better-auth/dist/plugins/two-factor/index.mjs (disableTwoFactor creates a new session and deletes the caller's), two-factor/totp/index.mjs (enrolment rotation), api/routes/update-user.mjs (changePassword with revokeOtherSessions).
Dedup: open domain:cli, domain:services and domain:runtime cards listed via REST and grepped for set-auth-token, twoFactor.disable, two-factor/disable 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 is scoped to narrowing published return types, and this is a runtime behaviour of the SDK's credential handling).Reproducible defect
ObjectStackClientauthenticates every request withAuthorization: Bearerfrom the tokenauth.login()stored (this.token). Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it inSet-Cookie(and the bearer plugin'sset-auth-tokenresponse header), and delete the old row:POST /two-factor/disable— answers{ status: true }only; the new token rides the cookie /set-auth-tokenheader.POST /two-factor/verify-totpon the ENROLMENT lane (a signed-in user confirming a new factor) — answers{ token, user }with the live token (plugin-auth'stwo-factor-rotated-token-echorepairs the vendor's stale echo).POST /change-passwordwithrevokeOtherSessions: true— answers{ token, user }with the new token.The SDK reads none of them:
packages/client/src/index.tshas zero reads of theset-auth-tokenheader (measured withgrep), andtwoFactor.disable,twoFactor.verifyTotpandchangePasswordallreturn res.json()without touchingthis.token. So after any of the three, the SDK keeps presenting the DELETED session's token and its next call answers401 UNAUTHORIZED.Measured on 2026-09-07 against the real
AuthPluginmounts over a real Hono app with a realAuthManager(better-auth 1.7.2) on a realSqlDriver(better-sqlite3), driven THROUGH the realObjectStackClientwith only the socket stood in for (fetch: (u, init) => app.request(u, init)):Through the raw route with a cookie jar the same
disablecall stages a freshsession_tokencookie (oneSet-Cookie), so a browser caller is fine; only the bearer path — the SDK's own mode — is signed out.Expected
The SDK keeps the session it was just handed: store the token echoed by
changePassword/verifyTotpwhen present (the waylogin()andrefreshToken()already do), and read the bearer plugin'sset-auth-tokenresponse header for the routes that echo no token (twoFactor.disable) — or document that bearer callers must re-login after these calls.Where
packages/client/src/index.ts—auth.changePassword,auth.twoFactor.verifyTotp,auth.twoFactor.disable, and thefetchwrapper (noset-auth-tokenread).better-auth/dist/plugins/two-factor/index.mjs(disableTwoFactorcreates a new session and deletes the caller's),two-factor/totp/index.mjs(enrolment rotation),api/routes/update-user.mjs(changePasswordwithrevokeOtherSessions).Dedup: open
domain:cli,domain:servicesanddomain:runtimecards listed via REST and grepped forset-auth-token,twoFactor.disable,two-factor/disableand bearer/rotation — zero hits, with the control termsexported-any-returnsandbetter-authanswering.