Account API
Password changes, forced rotation, and session revocation
The account API contains the signed-in user's credential change operation. A successful password change signs the browser out through the change-password page because the change revokes every live session for that account.
POST /api/account/password
Authentication: An existing session is required. Success: 200.
| Field | Type | Required? | Constraints |
|---|---|---|---|
currentPassword | string | Yes | 1–128 characters; compared with the current bcrypt hash. |
newPassword | string | Yes | 14–128 characters; must include uppercase, lowercase, digit, and special character. |
The 14-character rule applies together with all four character-class requirements. The new password must also differ from the current password.
curl -X POST http://localhost:3000/api/account/password \
-H 'content-type: application/json' \
-H 'cookie: next-auth.session-token=SESSION' \
-d '{"currentPassword":"OldPassword2026!","newPassword":"NewHarborPassword2026!"}'Success:
{"data":{"changed":true}}The endpoint allows five attempts per user in a 15-minute window. It returns:
| Status | Meaning |
|---|---|
| 200 | The password was changed. |
| 400 | The body is malformed, a field fails validation, or the new password equals the current password. |
| 401 | No session exists. |
| 403 | The current password does not match, or credential changes are locked (see below). |
| 429 | The per-user five-attempt, 15-minute limit was exceeded. |
| 500 | An unexpected server error occurred; details stay in the operational log. |
On success, the route stores the new bcrypt hash, clears mustChangePassword, and increments sessionVersion. The JWT callback then revokes every live token carrying the previous version, including the caller's token when it refreshes. The change-password page signs the caller out immediately and sends them back to /login.
When credentials are locked
The handler's first check is credentialChangesLocked(), before validation and before the rate limiter. When the lock is on — which is the default, since only the literal string false in DEMO_LOCK_CREDENTIALS disables it — every request returns 403 with CREDENTIAL_CHANGES_LOCKED_MESSAGE and no password is written. The page at /account/password renders the same message in place of the form, and the forced-rotation gate stands down so nobody is trapped on a page they cannot complete.
The route emits password_change_success on success, password_change_failure for validation failures, a current-password mismatch, or an unchanged password, and password_change_rate_limited when the rate limit is reached. Accounts with mustChangePassword are subject to the password rotation gate.
