resetPassword is not atomic. It hashes the candidate, reads the account list, and only then creates a credential row (node_modules/better-auth/dist/api/routes/password.mjs:151-158). Under the Convex adapter those steps are separate transactions: the adapter declares transaction: false and auth routes run as HTTP actions (@convex-dev/better-auth/src/client/adapter.ts:181-188, create-client.ts:389-398). Nothing enforces uniqueness on (userId, providerId).
So for an account that has no credential row yet, a write landing between the read and the create produces two credential rows. Password verification takes the first one (node_modules/better-auth/dist/utils/password.mjs:3-9), which is the row the racer wrote, not the row the reset wrote. The owner's reset reports success, revokes every session, and leaves the other password working.
api.users.setPassword is the second writer that makes this reachable, so this arrives with #808. The practical bar is high: the racer needs an authenticated session on the account that is under 24 hours old, and has to land inside the window between the reset's read and its create. Someone holding such a session can already set a password without racing at all; the race only changes who wins when the owner is recovering at that exact moment, which is when it matters most.
Repairing it inside setPassword does not work. Its own writes are transactional, because calling auth.api.setPassword from inside a Convex mutation nests the adapter's writes in that mutation; the non-atomic reader is the reset path, and no post-write check on our side can see a row the reset has not written yet. The option that does close it is refusing setPassword while an unconsumed reset token exists for the user, which costs every legitimate user a blocked settings form for up to the token's lifetime. That trade-off wants deciding on its own rather than inside a feature PR.
Upstream is the better fix: resetPassword should upsert the credential row in one step, or the account table should carry a uniqueness constraint that makes the second create fail.
resetPasswordis not atomic. It hashes the candidate, reads the account list, and only then creates acredentialrow (node_modules/better-auth/dist/api/routes/password.mjs:151-158). Under the Convex adapter those steps are separate transactions: the adapter declarestransaction: falseand auth routes run as HTTP actions (@convex-dev/better-auth/src/client/adapter.ts:181-188,create-client.ts:389-398). Nothing enforces uniqueness on(userId, providerId).So for an account that has no credential row yet, a write landing between the read and the create produces two credential rows. Password verification takes the first one (
node_modules/better-auth/dist/utils/password.mjs:3-9), which is the row the racer wrote, not the row the reset wrote. The owner's reset reports success, revokes every session, and leaves the other password working.api.users.setPasswordis the second writer that makes this reachable, so this arrives with #808. The practical bar is high: the racer needs an authenticated session on the account that is under 24 hours old, and has to land inside the window between the reset's read and its create. Someone holding such a session can already set a password without racing at all; the race only changes who wins when the owner is recovering at that exact moment, which is when it matters most.Repairing it inside
setPassworddoes not work. Its own writes are transactional, because callingauth.api.setPasswordfrom inside a Convex mutation nests the adapter's writes in that mutation; the non-atomic reader is the reset path, and no post-write check on our side can see a row the reset has not written yet. The option that does close it is refusingsetPasswordwhile an unconsumed reset token exists for the user, which costs every legitimate user a blocked settings form for up to the token's lifetime. That trade-off wants deciding on its own rather than inside a feature PR.Upstream is the better fix:
resetPasswordshould upsert the credential row in one step, or the account table should carry a uniqueness constraint that makes the second create fail.