Logging in with a second factor
Signing inThe account has an MFA method enrolled, so login returns a challenge instead of tokens — even on a recognised device (LE3). A recognised device never skips the MFA hop: device trust is a convenience, an enrolled second factor is a commitment.
- Current shape · POST /v1/auth/login → { challengeToken, challenge: { type: mfa_phone_otp | mfa_totp, hint }, remaining: 1 } (for mfa_phone_otp the OTP is sent as the challenge is issued)
- Current shape · POST /v1/auth/login/challenge/verify → accessToken + refresh cookie (amr: [pwd, mfa])
- Legacy shape (DEVICE_TRUST_ENABLED off) · login → { mfaRequired, method, phoneHint, mfaToken }, then mfa/otp/request (phone_otp only), then mfa/verify
/v1/auth/loginpublicLog in (identifier + password)With an enrolled method the answer is a challenge, never tokens. Current shape: { challengeToken, challenge: { type: mfa_phone_otp | mfa_totp, hint }, remaining: 1 } — for mfa_phone_otp the OTP goes out as the challenge is issued; for mfa_totp there is nothing to send, the code comes from the authenticator app. Legacy shape (DEVICE_TRUST_ENABLED off, or a pre-Enhancement-4 client): { mfaRequired: true, method, phoneHint, mfaToken } and no OTP is sent at this step.
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/v1/auth/login/challenge/verifypublicClear a login challenge hopSubmit the code — or a recovery code, which is single-use and consumed on success — to finish the chain: accessToken + refresh cookie, amr [pwd, mfa]. Clearing an MFA hop NEVER earns device trust on its own, otherwise a phished MFA code would silence the new-device control on the attacker's machine. Challenge tokens are short-lived (LOGIN_CHALLENGE_TTL_SECONDS, default 300s) — if one expires, restart at login. Repeated bad codes are throttled → 429.
Field guide — what each value means & where it comes from4 fields
Pre-authentication (no Authorization header — the challengeToken IS the authorization for this hop). Advances the login challenge chain by exactly one hop: hops remaining → a fresh challengeToken and the next challenge; plan exhausted → the session. Throttled, no-store.
challengeTokenbodystringrequiredfrom a responsecodebodystringrequiredout-of-bandtrustDevicebodybooleanoptionalyou choosetrue— THE DEFAULT. On completing a device hop a trusted_devices row is created and the device_token cookie is set, so the next login here skips the device challenge for 30 days (sliding). An unticked “remember this device” box would turn every work-laptop login into an OTP, which trains users to click through challenges.false— Send this explicitly on a shared machine: the login still succeeds and still earns amr [pwd, device], it simply mints no cookie, so the next visit is challenged again.
clientIdbodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/login/challenge/verifyResponse guide — what comes back & what each value means18 fields
200 with one of TWO main shapes: hops remain → the next challenge and a FRESH challengeToken (the one you just used is spent); the plan is exhausted → the ordinary session shape, identical to a no-challenge login. On completion with new trust a device_token cookie is set (HttpOnly; Secure; SameSite=Lax; Path=/v1/auth) — the secret is NEVER in the body. no-store. It can also end in one of the two SESSIONLESS 200s of §3.6 when the chain clears for an account that belongs nowhere — { noOrganization, bootstrapToken, expiresIn } (scenario 15) or { invitationPending, acceptToken, expiresIn, invitations[] } (scenario 52): neither mints a session row, a refresh or device cookie, an access token or a tenant_id claim. READ the body; a 200 alone is not "signed in".
data.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_email_code— A code was emailed — this is an email-mode account on a new device (LN2).device_phone_otp— An OTP was sent by phone — a phone-mode account on a new device (LN1); the challenge always goes out on the account's primary channel.mfa_phone_otp— The phone-OTP second factor; the OTP is sent as the challenge is issued.mfa_totp— The authenticator-app second factor. NOTHING is sent — the code comes from the app — so no resend applies and the login-challenge-code helper returns no code for it.
data.challenge.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.accessTokenstringconditionaldata.tokenTypestringconditionaldata.expiresInnumberconditionaldata.userobjectconditionalSet-Cookie: refresh_tokencookieconditionalSet-Cookie: device_tokencookieconditionaldata.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/v1/auth/mfa/otp/requestpublicSend/resend login MFA OTP (deprecated)DEPRECATED but fully working (legacy single-hop path). phone_otp method only: sends the second-factor OTP to the masked phoneHint; re-call to resend. For the totp method this step does not apply. A bad/expired mfaToken → 401; no verified phone on file → 409 no_verified_phone.
Field guide — what each value means & where it comes from1 field
DEPRECATED, STILL WORKING (scenario 37): the legacy single-hop MFA challenge, returned by login only when DEVICE_TRUST_ENABLED is off; new clients follow the challenge chain (POST /v1/auth/login/challenge/verify, scenario 12). JSON body with one field — the mfaToken from the login MFA challenge. No access token exists yet; the mfaToken is the only credential. This applies to phone_otp challenges only (a totp challenge needs no send). 202; re-calling resends and replaces the live challenge (throttled → 429). No verified phone on file → 409 no_verified_phone.
mfaTokenbodystringrequiredfrom a responsePOST https://api.kerja.team/v1/auth/mfa/otp/requestResponse guide — what comes back & what each value means1 field
202 Accepted, { data } envelope, Cache-Control: no-store, when the MFA OTP was sent (or resent — re-calling replaces the live code). The masked destination was already given as phoneHint in the login response. Not uniform: a bad / expired mfaToken is 401 and an account with no verified phone on file is 409 no_verified_phone (reveal).
data.statusenumalwaysaccepted— The OTP request was accepted for delivery over SMS or WhatsApp (channel chosen downstream). The only value on a 202.
/v1/auth/mfa/verifypublicVerify MFA challenge (deprecated single-hop)DEPRECATED but fully working — rewired onto the same engine as the challenge chain with a one-element plan and the same acceptance order (TOTP → recovery code → phone OTP). code is a phone OTP, a TOTP code, or a single-use recovery code. Success auto-captures data.accessToken; amr becomes [pwd, mfa]. New clients should use POST /v1/auth/login/challenge/verify (scenario 12/13), which can also express a chain.
Field guide — what each value means & where it comes from2 fields
DEPRECATED, STILL WORKING (scenario 37): completes the legacy single-hop MFA challenge; new clients follow the chain at POST /v1/auth/login/challenge/verify (scenario 12). Two body fields; no Authorization header — the mfaToken issued by login is the credential. Sent with Cache-Control no-store. On success the full session (amr [pwd, mfa]) — or, for an account that belongs to no organization, the noOrganization / invitationPending body of POST /v1/auth/login (scenario 15 / 52).
mfaTokenbodystringrequiredfrom a responsecodebodystringrequiredauthenticatorPOST https://api.kerja.team/v1/auth/mfa/verifyResponse guide — what comes back & what each value means15 fields
200 OK with a { data } envelope and Cache-Control: no-store. Normally issues the full session (same shape as a non-MFA login): Set-Cookie refresh_token (httpOnly) plus the access token and user in the body; amr is [pwd, mfa] whichever factor (phone OTP, TOTP, recovery code — the latter consumed) was used. It can also end in one of the two SESSIONLESS 200s of §3.6 when the chain clears for an account that belongs nowhere — { noOrganization, bootstrapToken, expiresIn } (scenario 15) or { invitationPending, acceptToken, expiresIn, invitations[] } (scenario 52): neither mints a session row, a refresh or device cookie, an access token or a tenant_id claim. READ the body; a 200 alone is not "signed in".
data.accessTokenstring (JWT)conditionaldata.tokenTypeenumconditionalBearer— Send the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberconditionaldata.user.idUUIDconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdUUIDconditionalSet-Cookie: refresh_tokencookieconditionaldata.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