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. The answer is one of three shapes — tokens, a challenge chain, or the legacy MFA challenge — and 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 means16 fields
200 OK, { data } envelope, Cache-Control: no-store. THREE possible shapes, and the login response itself is the only way to learn which branch 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. Only the token branch sets Set-Cookie: refresh_token.
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.mfaTokenstringconditional/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 means11 fields
200 with one of TWO 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.
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_tokencookieconditional/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
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.
mfaTokenbodystringrequiredfrom a responsePOST https://api.kerja.team/v1/auth/mfa/otp/requestResponse guide — what comes back & what each value means1 field
202 Accepted, uniform { data } envelope, Cache-Control: no-store. Always returns the same body regardless of outcome (anti-enumeration); the masked destination was already given as phoneHint in the login response. Single shape only.
data.statusstringalwaysaccepted— The OTP request was accepted for delivery over SMS or WhatsApp (channel chosen downstream). This is the only possible value.
/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
Two body fields complete the login MFA challenge; no Authorization header — the mfaToken issued by login is the credential. Sent with Cache-Control no-store.
mfaTokenbodystringrequiredfrom a responsecodebodystringrequiredauthenticatorPOST https://api.kerja.team/v1/auth/mfa/verifyResponse guide — what comes back & what each value means7 fields
200 OK with a { data } envelope and Cache-Control: no-store. Issues the full session (same shape as a non-MFA login): a Set-Cookie refresh_token (httpOnly) plus the access token and user object in the body.
data.accessTokenstringalwaysdata.tokenTypestringalwaysBearer— Send the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberalwaysdata.user.idstringalwaysdata.user.emailstringalwaysdata.user.displayNamestringalwaysdata.user.tenantIdstringalways