Changing your password while signed in
Profile & credentialsSince §153 the current password never travels in the change body: a step-up proves it, the new password is HELD on a change row while a code goes to your sign-in channel, and confirming the code applies it — ending every session (the current one included) but keeping this device trusted.
- POST /v1/me/step-up { action: password_change, currentPassword } → a step-up token; loginChannel names where the confirmation code will go. The password alone is enough here
- POST /v1/me/password-change { newPassword, stepUpToken } → 202; the new password is held on the change row and a code goes to the current value of the loginMode channel. The OLD password still signs in
- Optional: POST /v1/me/password-change/resend → a fresh code · DELETE /v1/me/password-change → discard the held password, nothing changes
- POST /v1/me/password-change/verify { code } → password replaced; ALL sessions revoked (this one too); every device trust revoked EXCEPT this device; password_changed sent to every verified channel
- POST /v1/auth/login → re-authenticate (no device challenge on this machine; one appears on any other)
/v1/me/step-upauthStart a step-up for password_changeProves the CURRENT password and answers { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? } — both ids are captured and filled into the cards below. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice is 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices; when false, prove one of options[] (a code to a verified, non-young CURRENT value — never a pending one — a TOTP, or a recovery code) for a strong token. Errors: wrong password 401 step_up_failed (counted per user AND per session — 5 in 15 min → 429, and a second throttle trip within 24 h revokes the session); 403 without an acting session (operator / act-as contexts have none); 409 no_password (GET /v1/me → hasPassword: false — set one through the reset flow, scenario 34); 409 change_in_progress when a live change blocks this action (details[0].blocking). For password_change passwordOnlyAllowed is true, so no factor is needed. loginChannel { channel, masked } names the channel the confirmation code will go to — the current value of loginMode's channel, which must be verified (else 409 login_channel_unavailable). Refused 409 change_in_progress while any contact change is live — a password change never sits beside one.
Field guide — what each value means & where it comes from2 fields
Starts a step-up (E-1) for ONE named profile action: proves the CURRENT password and answers 200 { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? }. Every profile mutation — display name, login mode, email / phone / password change, phone removal, MFA disable, recovery-code regeneration — spends one of these tokens; a live session alone is never enough. Auth + throttled + no-store; the body is .strict(). THE TESTER AUTO-CAPTURES BOTH IDS: challengeId is filled into the …/code and …/verify cards on this page, and stepUpToken into the card that spends it. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice → 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices for the action; when false, continue with …/code (for a code factor) and …/verify for a strong token. options[] are the factors …/verify will accept: a code factor always names the CURRENT value on the account — never a pending one — and only when it is verified and not young (applied with password assurance inside CHANNEL_TRUST_AGE_SECONDS, default 30 days); totp appears when a confirmed authenticator exists, recovery_code while unused codes remain. The challenge lives STEP_UP_CHALLENGE_TTL_SECONDS (default 600) and takes STEP_UP_MAX_ATTEMPTS (default 5) wrong codes. Errors: 401 step_up_failed (wrong password — counted per user AND per session, 5 in 15 min → 429, a second throttle trip within 24 h revokes the session) · 403 without an acting session (operator / act-as contexts have none) · 409 no_password (GET /v1/me → hasPassword: false; set one through the reset flow, scenario 34) · 409 change_in_progress when a live change blocks the action (details[0].blocking lists { kind, status }) · 409 login_channel_unavailable (password_change with no verified loginMode channel). Audited step_up_succeeded / step_up_failed.
actionbodyenumrequiredyou choosedisplay_name— For PUT /v1/me/display-name (scenario 28). passwordOnlyAllowed: true — the password token is enough. Never blocked by a live change.email_change— For POST /v1/me/email-change (scenario 30). passwordOnlyAllowed: true on an account WITHOUT confirmed TOTP; false with one, and for a second contact change beside a strong one. A PASSWORD-assurance change is HELD 3 days after its code and sends notices with a cancel link; a STRONG one applies at once. Blocked (409 change_in_progress) by a live email or password change, and beside a live phone change unless both are strong.phone_change— For POST /v1/me/phone-change (scenario 31, and the link-mode add of scenario 4). Same rules as email_change: password enough without confirmed TOTP; held vs applied by assurance; one live phone change at a time, never beside a password change, beside an email change only when both are strong.password_change— For POST /v1/me/password-change (scenario 35). passwordOnlyAllowed: true. The answer carries loginChannel — the CURRENT loginMode channel the confirmation code will go to, which must be verified (else 409 login_channel_unavailable). Blocked by, and blocks, any live contact change.login_mode— For PUT /v1/me/login-mode (scenario 33). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.phone_remove— For DELETE /v1/me/phone (no §2 scenario walks this any more — the doc dropped “Removing a phone credential” at its 2026-09-27 revision, while §3.31 and this action stay live). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.mfa_disable— For DELETE /v1/me/mfa (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.mfa_recovery_codes— For POST /v1/me/mfa/recovery-codes (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.
currentPasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/me/step-upResponse guide — what comes back & what each value means12 fields
200 OK, { data } envelope, Cache-Control: no-store. The current password was proven for ONE named action, and this is the PASSWORD-assurance step-up token plus the factors that can raise it to strong. Every profile mutation (display name, email / phone / password change, login mode, phone removal, MFA disable, recovery codes) spends one of these tokens — a live session alone is never enough. The tester captures data.challengeId and data.stepUpToken and fills them into the next cards. 401 step_up_failed = wrong password (5 in 15 min → 429; a second throttle trip in 24 h revokes the session); 409 no_password / change_in_progress / login_channel_unavailable refuse BEFORE a token is minted.
data.challengeIdUUIDalwaysdata.stepUpTokenstring (43-char opaque)alwaysdata.assuranceenumalwayspassword— Always this value here — only the current password has been proven. A strong token comes only from POST /v1/me/step-up/{challengeId}/verify.
data.expiresInnumberalwaysdata.passwordOnlyAllowedbooleanalwaystrue— Spend stepUpToken now: display_name and password_change always; a contact change on an account without confirmed TOTP (that change is then HELD after its code). Raising to strong is still possible and makes a contact change apply at once.false— Password is not enough: login_mode, phone_remove, mfa_disable, mfa_recovery_codes, and a second contact change beside a strong one. Continue with /code (for a code factor) and /verify to get a strong token.
data.optionsarrayalwaysdata.options[].factorenumalwaysemail_code— A code to the verified current email: send it with /code, then /verify.phone_code— A WhatsApp/SMS code to the verified current phone: send it with /code, then /verify.totp— Offered when a confirmed authenticator exists. Nothing to send — go straight to /verify with the app's code.recovery_code— Offered while unused recovery codes remain. Nothing to send — /verify with one; it is consumed.
data.options[].roleenumconditionalcurrent_value— The channel whose value this very action changes (e.g. the current email in an email change) — proving it confirms the owner of the old value.other_channel— The account's OTHER verified channel — proving it shows control of a second, independent credential.channel— The role used for the actions that change no channel value of their own — login_mode, phone_remove and the two MFA actions: every eligible channel is simply offered as a channel. (For phone_remove the phone is still offered as current_value, because that IS the value being removed.)
data.options[].maskedstringconditionaldata.loginChannelobjectconditionaldata.loginChannel.channelenumconditionalemail— The password-change code will be emailed.phone— The password-change code will go to the phone by WhatsApp/SMS.
data.loginChannel.maskedstringconditional/v1/me/password-changeauthRequest the change (the new password is held)newPassword is 12–1024 chars; the current password is NOT sent again — the step-up already proved it. 202 with the CredentialChange row (kind password, value null, codeSentTo masked). The new password is hashed and held on the change row ONLY; the OLD password signs in until the code is confirmed. The code goes to the CURRENT value of the loginMode channel, never to a pending one. 409 change_in_progress (any live contact change), 409 login_channel_unavailable, 409 no_password, 422 unchanged when the new password equals the current one. No notice at request or cancel. Expires unverified after CONTACT_CHANGE_PENDING_TTL_SECONDS.
Field guide — what each value means & where it comes from3 fields
Requests a password change (E-13) on a password_change step-up token — the CURRENT password was proven at POST /v1/me/step-up, so it is not sent again. 202 with the CredentialChange row (kind password, value null, codeSentTo masked). The new password is hashed and HELD on the change row only; a code goes to the CURRENT value of the loginMode channel, never to a pending one; the OLD password signs in until the code is confirmed. No notice at request or cancel. Expires unverified after CONTACT_CHANGE_PENDING_TTL_SECONDS. Auth + no-store; .strict(). Errors: 401 step_up_required · 409 change_in_progress (any live contact change) · 409 login_channel_unavailable (the loginMode channel has no verified value) · 409 no_password · 422 unchanged (the new password equals the current one). Audited credential_change_requested (reason password). Walk-through: scenario 35.
Idempotency-KeyheaderUUIDoptionalclient-generatednewPasswordbodystringrequiredyou choosestepUpTokenbodystringrequiredauto-capturedPOST https://api.kerja.team/v1/me/password-changeResponse guide — what comes back & what each value means11 fields
202 Accepted, { data } envelope, Cache-Control: no-store — the CredentialChange row with kind password, value null and codeSentTo masked (scenario 35). Spends a password_change step-up token (password assurance is enough — the current password was proven there, so it is not sent again). The new password is hashed and held on the row only; a code goes to the CURRENT value of the loginMode channel (the loginChannel step-up named), never to a pending one. The OLD password keeps signing in until the code is confirmed. No notice at request. Refusals: 401 step_up_required, 409 change_in_progress (any contact change live), 409 login_channel_unavailable, 409 no_password, 422 unchanged (same as the current password).
data.idUUIDalwaysdata.kindenumalwaysemail— An email change (scenario 30) — the code went to the NEW address; confirm at POST /v1/me/email-change/verify.phone— A phone add or replacement (scenario 31) — the WhatsApp/SMS code went to the NEW number; confirm at POST /v1/me/phone-change/verify.password— A password change (scenario 35) — the code went to the CURRENT value of the loginMode channel; confirm at POST /v1/me/password-change/verify. value is always null.
data.valuestring | nullalwaysdata.statusenumalwaysawaiting_verification— The code is out and not yet confirmed. Next: the matching …/verify with the code, …/resend for a fresh one (the old code dies), or DELETE to cancel. The current value keeps signing in. Lapses to expired after CONTACT_CHANGE_PENDING_TTL_SECONDS (24 h).held— Contact changes only: the code was confirmed on PASSWORD assurance, so the change waits CONTACT_CHANGE_HOLD_SECONDS (3 days) and worker job J7 applies it at effectiveAt. Every verified current channel was sent contact_change_held with a cancel link (POST /v1/auth/credential-change/cancel); DELETE also still cancels it. The current value keeps signing in until then.applied— In effect: the new email / phone is the verified credential (user.upserted projected across the user's tenants), or the new password is set and every session has ended. Terminal.cancelled— Cancelled by the user (DELETE on the change route, DELETE /v1/me/credential-changes) or through the notice link. The current value was never touched. Terminal.expired— Never confirmed within the pending TTL; closed by J7 with no notice. Terminal — start a new change (and a new step-up) to try again.failed— At verify the target address / number had meanwhile been taken by another account; contact_change_failed was sent. Terminal.
data.assuranceenumalwayspassword— Only the current password was proven: a contact change is HELD after its code (3-day window, cancel link sent to every verified channel at request and at hold). A password change needs no more than this.strong— A second factor was proven at POST /v1/me/step-up/{challengeId}/verify: a contact change APPLIES the moment its code is confirmed, and nothing is sent at request.
data.requestedAtISO-8601alwaysdata.verifiedAtISO-8601 | nullalwaysdata.effectiveAtISO-8601 | nullalwaysdata.expiresAtISO-8601alwaysdata.codeExpiresAtISO-8601 | nullalwaysdata.codeSentTostring | nullalways/v1/me/password-change/resendauthOptional — resend the code202 { ttlSeconds } — a fresh code to the same destination; the previous code dies. 409 no_pending_change; 429 on the per-change / per-destination throttle.
Field guide — what each value means & where it comes fromno inputs
No body. Sends a fresh code to the same destination (E-15) — the current loginMode channel; the previous code dies. 202 { ttlSeconds }. 409 no_pending_change when no password change is awaiting its code; 429 on the per-change / per-destination throttle.
POST https://api.kerja.team/v1/me/password-change/resendResponse guide — what comes back & what each value means1 field
202 Accepted, { data } envelope, Cache-Control: no-store. A fresh code went to the same loginMode destination; the previous code is dead. 409 no_pending_change when no password change awaits its code; 429 on the per-change / per-destination throttle.
data.ttlSecondsnumberalways/v1/me/password-changeauthOptional — discard the held password204, idempotent, no step-up. The held hash is discarded and nothing changes; no notice is sent (there is nothing to name).
Field guide — what each value means & where it comes fromno inputs
No body. Cancels the live password change (E-15). 204, idempotent, NO step-up. The held hash is discarded, the old password keeps working, and no notice is sent — there is nothing to name.
DELETE https://api.kerja.team/v1/me/password-changeResponse guide — what comes back & what each value meansno body
204 No Content — no body. Cancels the live password change; idempotent, no step-up. The held hash is discarded and NO notice is sent (there is nothing to name). The old password was never replaced.
/v1/me/password-change/verifyauthConfirm the code — the password is replacedApplies the new password AT ONCE (no hold — a password change is not a contact change). 200 { status: applied }. EVERY SESSION ENDS, THE CALLING ONE INCLUDED — your access/refresh tokens stop working right after this call, so plan the re-login into the UX. Open login and device challenges are consumed. Every device trust is revoked EXCEPT the device this session runs on (unlike a reset, scenario 34) — it is resolved from the session BEFORE the sessions are revoked, and if it cannot be identified, all devices are untrusted (fail-closed). Every verified channel receives password_changed. 401 invalid_code (the 5th wrong code burns it); 409 no_pending_change.
Field guide — what each value means & where it comes from1 field
Confirms the code and applies the new password AT ONCE (E-14) — there is no hold for a password. 200 { status: applied }. EVERY SESSION ENDS, THE CALLING ONE INCLUDED, so the bearer this tester holds stops working — sign in again with the NEW password. Open login and device challenges are consumed; every device trust is revoked EXCEPT the device this session runs on; every verified channel receives password_changed; the held hash is cleared from the change row. Nothing is projected. Audited password_changed. Errors: 401 invalid_code (wrong, expired or used; the 5th wrong code burns it) · 409 no_pending_change.
codebodystringrequiredout-of-bandPOST https://api.kerja.team/v1/me/password-change/verifyResponse guide — what comes back & what each value means1 field
200 OK, { data } envelope, Cache-Control: no-store. The code was confirmed and the new password is applied AT ONCE (no hold for the password kind). EVERY SESSION ENDS — THE CALLING ONE INCLUDED, so the tester's current bearer / refresh cookie are dead after this: sign in again with the new password. Open login and device challenges are consumed; every device trust is revoked EXCEPT the device this session ran on; every verified channel receives password_changed; the held hash is cleared from the row. Audits password_changed; nothing is projected. 401 invalid_code (5th wrong code burns it), 409 no_pending_change.
data.statusenumalwaysapplied— The only value: the new password is in force and all sessions are revoked. Next step is a fresh login (scenario 9 / 10); this browser stays trusted, so no new-device challenge.
/v1/auth/loginpublicLog in (identifier + password)identifier is an email OR an E.164 phone — the shape decides the lookup ({ email } is still accepted as a deprecated alias) — and it is OPTIONAL on a recognised device, where the device_token cookie names the account and the body needs only { password }. A 200 HERE IS A BODY TO READ, NOT A STATUS TO TRUST — five answers share it: { accessToken, … } (signed in — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — scenario 11/12/13), the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12), { noOrganization: true, bootstrapToken, expiresIn } (the credential is good but you belong nowhere — scenario 15), or { invitationPending: true, acceptToken, expiresIn, invitations[] } (your only seat is an invitation you have not accepted — scenario 52). The last two mint NOTHING session-shaped: no session, no refresh or device cookie, no access token. Gated by the LOGIN policy before the credential is even checked → 403 login_method_disabled (scenario 7).
Field guide — what each value means & where it comes from4 fields
Public, unauthenticated login. No Authorization header; send only the JSON body below. Throttled per credential/IP; response is no-store. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. Which one you get is NOT checkable beforehand (there is no pre-login MFA-status endpoint, by anti-enumeration design). Before the credential is examined at all, the active LOGIN policy is checked: if the channel this attempt uses is off, the answer is 403 login_method_disabled for a right and a wrong password alike (which discloses nothing — GET /v1/auth/login-policy publishes the same fact unauthenticated). Read that policy first in scenario 7.
identifierbodystringconditionalyou chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse guide — what comes back & what each value means23 fields
200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which applies (there is no pre-login MFA-status endpoint, by anti-enumeration design): (1) tokens — nothing more to prove; (2) a CHALLENGE CHAIN { challengeToken, challenge, remaining } when a device and/or MFA hop is pending, continued at POST /v1/auth/login/challenge/verify; (3) the LEGACY single-hop { mfaRequired, method, phoneHint, mfaToken } when DEVICE_TRUST_ENABLED is off; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.
data.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_email_code— New browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.device_phone_otp— New browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.mfa_phone_otp— An enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.mfa_totp— An enrolled authenticator app — nothing is sent; the code comes from the app, so no resend applies.
data.challenge.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_otp— Send/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.totp— Read the code from the authenticator app — there is nothing to send, so skip the otp/request step.
data.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditionaldata.noOrganizationbooleanconditionaltrue— Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditionaldata.invitationPendingbooleanconditionaltrue— Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditionaldata.invitationsarrayconditionaldata.invitations[].tenantIdUUIDconditionaldata.invitations[].tenantNamestringconditional