Repository navigation
Conversation
…ns in with
Better Auth's password sign-in reads auth_account.password (provider 'credential').
Five SonicJS paths wrote only auth_user.password_hash:
- profile password change (POST /admin/profile/password)
- admin edit-user with a new password (PUT /admin/users/:id)
- admin create-user (POST /admin/users/new)
- password reset (POST /auth/reset-password)
- accept invitation (POST /auth/accept-invitation)
So a changed or reset password failed at sign-in while the old one kept working,
and admin-created or invited users could not sign in at all.
A small helper, credentialPasswordStatements(), returns the statements that update
the user's credential row (whatever its id; Better Auth's own sign-up gives it a
random one) or insert a `cred-${userId}` row when there is none. Each path now runs
them in the same db.batch as its auth_user write.
Reset and invitation links are now built from BETTER_AUTH_URL when it is set,
otherwise the origin of the URL the request was served on, never the request's
Origin header. /auth/request-password-reset is anonymous and CSRF-exempt, so the
Origin header let anyone have a real reset token emailed to a victim inside a link
to their own site; with resets now reaching Better Auth, that would be an account
takeover.
Existing sessions are not revoked on a reset or admin-set password; that is a
separate follow-up.
This was referenced Oct 13, 2026
mmcintosh
marked this pull request as ready for review
October 13, 2026 20:13
11 tasks done
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Better Auth signs in against
auth_account.password, the credential row. Five SonicJS paths set a password but write onlyauth_user.password_hash:POST /auth/reset-password(forgot password);POST /auth/accept-invitation;POST /admin/profile/password(change my password);PUT /admin/users/:id(admin sets a user's password);POST /admin/users/new(admin creates a user).So a reset or changed password fails at sign-in and the old one keeps working. Invited users and users created by an admin can't sign in until a later full bootstrap happens to backfill the row (up to 24 hours with the KV marker). The register form has the same bug, fixed in #1104.
This PR also fixes how the reset and invitation links are built. They came from the request's
Originheader./auth/request-password-resetis anonymous and CSRF-exempt, so anyone could have a real reset token emailed to a victim inside a link to their own site. Once this PR makes resets reach Better Auth, that would be an account takeover.No open issue. Found while testing the fixes for #1054.
Changes
auth/credential-account.ts,credentialPasswordStatements():cred-${userId}(INSERT … SELECT … WHERE NOT EXISTS);db.batchas theauth_userwrite, on all five paths above.utils/site-origin.ts,siteOrigin(): links useBETTER_AUTH_URLwhen it is set, otherwise the origin of the URL the request was served on, never theOriginheader. It is used for the reset link and both invitation links. Behind a proxy that doesn't pinHost(self-host or Docker), setBETTER_AUTH_URL.docker-compose.ymldefaultsBETTER_AUTH_URLtohttp://localhost:3000. Set it to the public URL, or emailed reset and invitation links point at that default.Testing
password-change-credential.integration.test.ts(new, 5 tests): each route is driven on real SQLite, then signed in through a real Better Auth instance. The new password returns 200; for profile change, admin edit and reset, the old one returns 401. It covers a Better Auth sign-up (update path) and an invited user (insert path). All 5 fail without the fix.reset-link-origin.integration.test.ts(new, 2 tests): withOrigin: https://evil.example, the emailed link usesBETTER_AUTH_URL, or the request's own URL when that isn't set. Both fail without the fix.Unit Tests
E2E Tests
115-password-change-login.spec.ts(@auth). Admin creates a user, then the user logs in. The admin then sets a new password: the new one works and the old one gets 401.Screenshots/Videos
N/A. No UI changes; the forms are the same, and the change is what the server writes.
Checklist
BETTER_AUTH_URLnote above