Skip to content

A successful /auth/change-password over a BEARER token never clears must_change_password — the caller is permanently locked out of every protected route with a 200 telling them it worked #8049

Description

@baozhoutao

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-passwordpassword really rotated?must_change_passwordpassword_changed_atprotected read after
Cookie lane200yesfalsestamped ✅200
Bearer lane200yesstays truestays null403 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

  1. As an admin: POST /api/v1/auth/admin/create-user {"email":"x@example.com","password":"OldPass!123"} → 200, response says mustChangePassword: true.
  2. Sign in as that user, take the bearer from the set-auth-token response header.
  3. GET /api/v1/data/showcase_task?limit=1403 {"code":"PASSWORD_EXPIRED"} (expected).
  4. POST /api/v1/auth/change-password {"currentPassword":"OldPass!123","newPassword":"NewPass!456"} with that bearer → 200.
  5. Re-sign-in with the new password → succeeds (proving the rotation landed); old password → 401.
  6. Repeat step 3 with the fresh token → still 403 PASSWORD_EXPIRED.
  7. 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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions