switching clears the token & responses
No token
34

Resetting a forgotten password

Profile & credentials

Start a reset from an email OR an E.164 phone (BR-8), redeem the emailed token or the SMS code to set a new password, then log in. The reset revokes every session and every device trust — and then re-trusts the browser that performed it (BR-9), so this machine is not challenged afterwards.

  1. POST /v1/auth/password/forgot → identifier = an email OR an E.164 phone (BR-8). Email → a 1h reset token; phone → a 5-min reset-purpose SMS OTP. Unknown → 404 account_not_found / phone_not_registered
  2. POST /v1/auth/password/reset → submit the emailed { token } OR { phone, code } + newPassword. Password replaced; ALL sessions revoked and ALL device trust revoked — then THIS browser is re-trusted from the proof it just gave (BR-9)
  3. POST /v1/auth/login → log in with the new password (no new-device challenge on this browser unless you sent trustDevice: false)
POST/v1/auth/password/forgotpublicForgot password (email or phone)

identifier is an email OR an E.164 phone, discriminated by SHAPE exactly as login is (BR-8); { email } is still accepted as a deprecated alias. Email → a password_reset token (1h TTL) out-of-band; phone → a reset-PURPOSE 6-digit OTP (~5 min), a distinct purpose so it can never satisfy a login / mfa / link / device challenge. REVEALS whether the identifier is registered, and does so symmetrically: unknown email → 404 account_not_found, unknown phone → 404 phone_not_registered — the phone case is deliberately not a quieter oracle than the email one. Audited as password_reset_requested. A phone-only account CAN reset here (that is what BR-8 added); it can also simply sign in with a one-time code instead (scenario 14).

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Public, throttled, no auth. Since BR-8 it takes an IDENTIFIER — an email OR an E.164 phone, discriminated by shape exactly as login is — and the two routes diverge from there: email mints a password_reset token (1h TTL) delivered out-of-band, phone mints a reset-PURPOSE 6-digit OTP (~5 min) into identity.otp_challenges, a distinct purpose so it can never satisfy a login / mfa / link / device challenge. It does NOT anonymise a miss: an unknown identifier is disclosed either way — unknown email is 404 account_not_found, unknown phone is 404 phone_not_registered — so the phone case is deliberately not a quieter oracle than the email one. Audited as password_reset_requested.

identifierbodystringconditionalyou choose
The account to start a password reset for: an email OR an E.164 phone number, typed freely. The SHAPE decides the route, not a mode flag — anything matching ^+[1-9]d{6,14}$ is resolved by phone, anything else as an email — so the reset screen does not have to know which kind of account it is addressing. Max 320 chars. Exactly one of identifier / email. Not a valid email or E.164 phone → 422.
emailbodystringconditionalyou choose
DEPRECATED alias for identifier, kept so existing clients do not break. Email-only by definition — a phone reset must use identifier. Supply identifier OR email, never both.
→ POST https://api.kerja.team/v1/auth/password/forgot
↩︎Response guide — what comes back & what each value meansno body

200 OK with the standard { data } envelope, where data is an empty object ({ "data": {} }) — there are no body properties to consume. THE 200 ITSELF IS THE INFORMATION: this endpoint deliberately REVEALS whether the identifier is registered rather than answering uniformly, and does so symmetrically — an unknown email is 404 account_not_found and an unknown phone is 404 phone_not_registered, so the phone case is not a quieter oracle than the email one. A 200 therefore means the account exists and the credential went out on its channel: the email route minted a single-use password_reset token (1h TTL), the phone route a reset-purpose SMS OTP (~5 min) whose distinct purpose keeps it from satisfying a login / mfa / link / device challenge. Audited as password_reset_requested. No Set-Cookie; no auth tokens returned.

POST/v1/auth/password/resetpublicReset password (token, or phone + code)

EXACTLY ONE credential: { token } (the emailed link, single-use) OR { phone, code } (the SMS OTP, BR-8) — both together is 422, and a bad token and a bad/expired/exhausted code are the same uniform 401. Replaces the password, revokes ALL sessions, and revokes ALL device trust including this browser's old device_token. THEN IT RE-TRUSTS THIS BROWSER (BR-9): the reset itself was a fresh proof of the channel, so a new device_token cookie is set and the next login here is not challenged. Every OTHER device stays untrusted, and the old cookie is never honoured — the trust is re-earned, not preserved. A reset also CANCELS EVERY LIVE CREDENTIAL CHANGE (email, phone and password alike — scenario 36): a forgotten password is a plausible symptom of compromise, so a change started from before it does not survive it. Send trustDevice: false on a shared computer to decline it, or when DEVICE_TRUST_ENABLED is off nothing is granted either way. (§2.23's gotcha list still says the resetting machine is challenged too; §3.11 is the newer and more specific text, and BR-9 is what the service implements.)

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from5 fields

Public, throttled endpoint — no Authorization or gateway context needed. The .strict() body carries EXACTLY ONE credential — the emailed { token }, or { phone, code } from the SMS (BR-8) — plus the new password. Success replaces the password hash, revokes ALL of the user's sessions and revokes ALL device trust, then RE-TRUSTS THE BROWSER THAT PERFORMED THE RESET from the proof it just gave (BR-9), setting a fresh device_token cookie (HttpOnly, Path=/v1/auth) that is never in the body. Every OTHER device stays untrusted and the old cookie is never honoured. A reset also cancels every live credential change (scenario 36). A bad token and a wrong / expired / exhausted code are the same uniform 401.

tokenbodystringconditionalout-of-band
The single-use password-reset token from the EMAIL route. You do not type this freely — obtain it from the side channel the reset link was delivered on (triggered by POST /v1/auth/password/forgot, §3.10) and paste it here, or mint one with the ⛓ helper. 1–512 chars. Supply token OR phone + code, never both (422); invalid or expired tokens return 401.
phonebodystringconditionalyou choose
The E.164 number the reset OTP was sent to (BR-8, the PHONE route). Typed by the user. Required together with code; supply phone + code OR token, never both.
codebodystringconditionalout-of-band
The reset-purpose SMS OTP (~5 min), numeric 1–10 digits. Delivered out-of-band — mint one with the ⛓ phone-OTP helper using purpose = reset. A reset OTP is purpose-scoped: a login / mfa / link / device code will not satisfy this call. Wrong, expired or attempt-exhausted is the same uniform 401 a bad token gives.
trustDevicebodybooleanoptionalyou choose
BR-9 opt-out. DEFAULTS TO TRUE: the reset revokes every device trust and then re-grants one to THIS browser, because completing the reset was itself a fresh proof of the channel — so the next login here raises no new-device challenge. Send false on a shared computer to decline the re-grant. Inert when DEVICE_TRUST_ENABLED is off, where nothing is granted either way.
newPasswordbodystringrequiredyou choose
The new password the user chooses. Must be ≥ 12 and ≤ 1024 chars. On success the password hash is replaced and all of the user's sessions are revoked.
→ POST https://api.kerja.team/v1/auth/password/reset
↩︎Response guide — what comes back & what each value means1 field

200 OK with the standard { data } envelope where data is an empty object ({ "data": {} }) — there is no body payload to consume; success simply confirms the reset completed. Everything that matters here is a side effect: the credential is consumed (the token is single-use, the SMS code likewise), the password hash is replaced, ALL of the user's sessions are revoked, and ALL device trust is revoked — including this browser's old device_token. THEN BR-9 RE-TRUSTS THIS BROWSER: unless trustDevice: false was sent (or DEVICE_TRUST_ENABLED is off), a FRESH device_token arrives as Set-Cookie (HttpOnly, Path=/v1/auth) minted from the proof the reset itself just performed, so the next login here raises no new-device challenge while every other device stays untrusted. The token value is never in the body. Audited as password_reset.

Set-Cookie: device_tokencookieconditional
The BR-9 re-grant. Present when device trust is enabled and trustDevice was not false. HttpOnly and scoped to Path=/v1/auth, so it rides along on the next POST /v1/auth/login from this browser and no page script can read it. It is a NEW grant, not the old cookie preserved — the reset revoked that one first, and the trust is re-earned from the reset's own proof of the channel.
POST/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).

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The credential being authenticated: an email OR an E.164 phone number. The SHAPE decides the lookup, not a mode flag — anything matching ^\+[1-9]\d{6,14}$ is resolved by phone, anything else as an email — so a client never has to know which kind of account it is addressing. The credential you type must itself be VERIFIED (an email login needs a verified email, a phone login a verified phone). OPTIONAL ON A RECOGNISED DEVICE: when the body carries no identifier and a valid device_token cookie is presented, the account is resolved from the trusted device and its primary channel is used. That is what lets the “Welcome back” screen (scenario 8) ask for nothing but a password — it only ever had a MASKED identifier to show, and could not otherwise complete the login it had just offered. It grants nothing: the password is still required, and a missing / garbage / expired / revoked cookie with no identifier answers the SAME uniform 401 invalid_credentials as a wrong password (never a 422 “identifier is required”, which would itself confirm the cookie was rejected). An explicit identifier always takes precedence.
emailbodystringconditionalyou choose
DEPRECATED ALIAS for identifier, still accepted and normalised server-side so existing clients keep working. Send identifier in new integrations. Valid email, at most 320 chars.
clientIdbodystringoptionalyou choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
passwordbodystringrequiredyou choose
The account password the user types. 1-1024 chars. Verified against the stored credential; wrong password yields a uniform invalid_credentials 401.
→ POST https://api.kerja.team/v1/auth/login
↩︎Response 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.accessTokenstringconditional
Non-MFA branch only (MFA disabled). The signed JWT access token; the client sends it as Authorization: Bearer for subsequent calls.
data.tokenTypestringconditional
Non-MFA branch only. Token scheme to use in the Authorization header.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
data.user.idstringconditional
Non-MFA branch only. The authenticated user's unique id.
data.user.emailstringconditional
Non-MFA branch only. The authenticated user's email.
data.user.displayNamestringconditional
Non-MFA branch only. Human-readable display name for the user, e.g. for UI greeting.
data.user.tenantIdstringconditional
Non-MFA branch only. The selected tenant for this session; if the user has multiple tenants, the default membership is chosen.
data.challengeTokenstringconditional
CHALLENGE branch only. The token that carries the ordered remaining plan and the accumulated amr — the server keeps no per-chain state. Copy it into POST /v1/auth/login/challenge/verify (or .../resend). Short-lived: LOGIN_CHALLENGE_TTL_SECONDS, default 300s.
data.challenge.typeenumconditional
CHALLENGE branch only. Which factor the first hop collects. A device hop always comes before an MFA hop, so the MFA hint is never leaked to an unrecognised machine.
  • device_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.hintstringconditional
CHALLENGE branch only. The masked destination the code went to (b••••@example.com, +••••••6789) — show it so the user knows which inbox or handset to check.
data.challenge.expiresInnumberconditional
CHALLENGE branch only. Seconds until this hop expires; when it lapses, start again at login.
data.remainingnumberconditional
CHALLENGE branch only. How many hops are still to clear, counting this one: 1 for a lone device or MFA challenge, 2 for the new-device-plus-MFA chain (LN3, scenario 13).
data.methodenumconditional
LEGACY MFA branch only. Which second factor to collect.
  • phone_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.phoneHintstringconditional
LEGACY MFA branch, phone_otp only. The masked destination the second-factor OTP will go to.
data.mfaRequiredbooleanconditional
MFA branch only (MFA enabled). Signals no token was issued and the caller must complete the MFA challenge via POST /v1/auth/mfa/verify (§3.8).
  • true — MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional
MFA branch only. A single-purpose JWT (~5 min TTL), NOT an access token. The client copies it into the follow-up MFA verify (and OTP request) calls to complete login.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.