Skip to content

fix(auth): password changes update the credential row Better Auth signs in with - #1105

Open
mmcintosh wants to merge 1 commit into
mainfrom
fix/password-change-credential
Open

mmcintosh wants to merge 1 commit into
mainfrom
fix/password-change-credential

Conversation

@mmcintosh

@mmcintosh mmcintosh commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Better Auth signs in against auth_account.password, the credential row. Five SonicJS paths set a password but write only auth_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 Origin header. /auth/request-password-reset is 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

  • New auth/credential-account.ts, credentialPasswordStatements():
    • it updates the user's existing credential row, whatever its id (Better Auth sign-ups use a random one);
    • if there is none, it inserts cred-${userId} (INSERT … SELECT … WHERE NOT EXISTS);
    • it runs in the same db.batch as the auth_user write, on all five paths above.
  • New utils/site-origin.ts, siteOrigin(): links use BETTER_AUTH_URL when it is set, otherwise the origin of the URL the request was served on, never the Origin header. It is used for the reset link and both invitation links. Behind a proxy that doesn't pin Host (self-host or Docker), set BETTER_AUTH_URL.
  • Self-host / Docker: docker-compose.yml defaults BETTER_AUTH_URL to http://localhost:3000. Set it to the public URL, or emailed reset and invitation links point at that default.
  • Not in this PR:
    • Existing sessions are not ended when a password is reset or set by an admin. SonicJS has no session-revocation mechanism yet; that's a follow-up.

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): with Origin: https://evil.example, the emailed link uses BETTER_AUTH_URL, or the request's own URL when that isn't set. Both fail without the fix.
  • The tests also pass on Better Auth 1.7.7 (fix(auth): move to Better Auth 1.7.7 and better-auth-cloudflare 0.4.1 #1103).

Unit Tests

  • Added/updated unit tests
  • All unit tests passing (this branch: 1937 / 0; all five batch PRs merged: 2000 / 0)

E2E Tests

  • Added/updated 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.
  • All E2E tests passing (not run locally, per the repo's E2E policy)

Screenshots/Videos

N/A. No UI changes; the forms are the same, and the change is what the server writes.

Checklist

  • Code follows project conventions
  • Tests added/updated and passing
  • Type checking passes
  • No console errors or warnings
  • Documentation updated (if needed): the self-host BETTER_AUTH_URL note above

…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 branch was successfully deployed

1 active deployment
internal — 87056e3f Deployed Oct 13, 2026 by mmcintosh via authorize #1609
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant