Symptom
An admin-provisioned user (POST /auth/admin/create-user, where mustChangePassword defaults to true) is gated out of every protected route with 403 PASSWORD_EXPIRED until they change their password — correct, that gate is doing its job. But on the bearer-token lane the escape hatch does not work: POST /auth/change-password answers 200, the password really rotates, and the caller stays locked out forever.
The defect is lane-specific and I measured both sides:
| change-password | password really rotated? | must_change_password | password_changed_at | protected read after |
|---|
| Cookie lane | 200 | yes | → false ✅ | stamped ✅ | 200 ✅ |
| Bearer lane | 200 | yes | stays true ❌ | stays null ❌ | 403 PASSWORD_EXPIRED ❌ |
So the console is fine. Every API/agent/CLI client authenticating with Authorization: Bearer — the documented API lane — has no working way out of the force-change gate.
Reproduction
- As an admin:
POST /api/v1/auth/admin/create-user {"email":"x@example.com","password":"OldPass!123"} → 200, response says mustChangePassword: true. - Sign in as that user, take the bearer from the
set-auth-token response header. GET /api/v1/data/showcase_task?limit=1 → 403 {"code":"PASSWORD_EXPIRED"} (expected).POST /api/v1/auth/change-password {"currentPassword":"OldPass!123","newPassword":"NewPass!456"} with that bearer → 200.- Re-sign-in with the new password → succeeds (proving the rotation landed); old password → 401.
- Repeat step 3 with the fresh token → still
403 PASSWORD_EXPIRED. - As admin, read the row:
must_change_password: true, password_changed_at: null.
Run the identical sequence with a cookie jar instead of a bearer and step 6 returns 200 with the flags correctly cleared. Reproduced on two fresh users per lane.
Root cause
The intent is right and the write is correct — it simply never runs on the bearer lane.
packages/plugins/plugin-auth/src/auth-manager.ts:4362stampPasswordChangedAt() does exactly the right thing:
// #2766 V1 — a completed password change also satisfies any pending// admin-issued force-change flag, so clear it in the same write.awaitengine.update('sys_user',{id: userId,password_changed_at: newDate(),must_change_password: false},{context: SYSTEM_CTX});The after-hook (:1452-1465) only calls it when the before-hook stashed ctx.context.__osPwChangeUserId, and that stash comes from resolvePasswordChangeUserId() (:4399-4404):
if(ctx?.path==='/change-password'){const{ getSessionFromCtx }=awaitimport('better-auth/api');constsess: any=awaitgetSessionFromCtx(ctx).catch(()=>null);returnsess?.user?.id??sess?.session?.userId??undefined;}getSessionFromCtx resolves the session from the cookie. On a bearer-authenticated request it returns null, so no user id is stashed, so the after-hook stamps nothing — while better-auth's own password write proceeds normally. Hence the 200 with no flag clear.
Second consequence on the same stash — a security control silently skipped
The same__osPwChangeUserId gate guards ADR-0069 D1's password-reuse rejection: the before-hook calls assertPasswordNotReused() only when the user id resolved, and stashes __osPwHistory for recordPasswordHistory() in the after-hook. On the bearer lane neither runs, so password history is neither checked nor recorded for bearer-lane changes. A control that is enforced on one transport and silently absent on the other is worse than one that is absent on both, because the pins and the console both exercise the working lane.
Suggested shape (not prescriptive)
Resolve the acting user the way the rest of the request pipeline already does — fall back to the bearer-derived principal when getSessionFromCtx yields nothing — rather than adding a second stamp site. The gate that keeps it from regressing is a test that drives /auth/change-passwordover both transports and asserts the same post-conditions (must_change_password === false, password_changed_at stamped, history appended, protected read 200); the current pins only exercise the cookie path.
Source
Found during the platform checklist retest of identity-auth.admin-lifecycle-operations (framework 279ee48a) and independently re-measured on both lanes before filing. Not caused by any of the fixes under retest.
Symptom
An admin-provisioned user (
POST /auth/admin/create-user, wheremustChangePassworddefaults to true) is gated out of every protected route with403 PASSWORD_EXPIREDuntil they change their password — correct, that gate is doing its job. But on the bearer-token lane the escape hatch does not work:POST /auth/change-passwordanswers 200, the password really rotates, and the caller stays locked out forever.The defect is lane-specific and I measured both sides:
change-passwordmust_change_passwordpassword_changed_atfalse✅true❌null❌So the console is fine. Every API/agent/CLI client authenticating with
Authorization: Bearer— the documented API lane — has no working way out of the force-change gate.Reproduction
POST /api/v1/auth/admin/create-user {"email":"x@example.com","password":"OldPass!123"}→ 200, response saysmustChangePassword: true.set-auth-tokenresponse header.GET /api/v1/data/showcase_task?limit=1→403 {"code":"PASSWORD_EXPIRED"}(expected).POST /api/v1/auth/change-password {"currentPassword":"OldPass!123","newPassword":"NewPass!456"}with that bearer → 200.403 PASSWORD_EXPIRED.must_change_password: true,password_changed_at: null.Run the identical sequence with a cookie jar instead of a bearer and step 6 returns 200 with the flags correctly cleared. Reproduced on two fresh users per lane.
Root cause
The intent is right and the write is correct — it simply never runs on the bearer lane.
packages/plugins/plugin-auth/src/auth-manager.ts:4362stampPasswordChangedAt()does exactly the right thing:The after-hook (
:1452-1465) only calls it when the before-hook stashedctx.context.__osPwChangeUserId, and that stash comes fromresolvePasswordChangeUserId()(:4399-4404):getSessionFromCtxresolves the session from the cookie. On a bearer-authenticated request it returns null, so no user id is stashed, so the after-hook stamps nothing — while better-auth's own password write proceeds normally. Hence the 200 with no flag clear.Second consequence on the same stash — a security control silently skipped
The same
__osPwChangeUserIdgate guards ADR-0069 D1's password-reuse rejection: the before-hook callsassertPasswordNotReused()only when the user id resolved, and stashes__osPwHistoryforrecordPasswordHistory()in the after-hook. On the bearer lane neither runs, so password history is neither checked nor recorded for bearer-lane changes. A control that is enforced on one transport and silently absent on the other is worse than one that is absent on both, because the pins and the console both exercise the working lane.Suggested shape (not prescriptive)
Resolve the acting user the way the rest of the request pipeline already does — fall back to the bearer-derived principal when
getSessionFromCtxyields nothing — rather than adding a second stamp site. The gate that keeps it from regressing is a test that drives/auth/change-passwordover both transports and asserts the same post-conditions (must_change_password === false,password_changed_atstamped, history appended, protected read 200); the current pins only exercise the cookie path.Source
Found during the platform checklist retest of
identity-auth.admin-lifecycle-operations(framework279ee48a) and independently re-measured on both lanes before filing. Not caused by any of the fixes under retest.