Logging in on a new device with a second factor
Signing inThe hardest case (LN3): a new browser AND an enrolled MFA method, so the user clears TWO challenges in sequence. This is the reason the single-hop mfaToken was replaced by a chain.
- POST /v1/auth/login → hop 1: { challengeToken, challenge: { type: device_email_code }, remaining: 2 }
- POST /v1/auth/login/challenge/verify → hop 2: a FRESH challengeToken + { challenge: { type: mfa_phone_otp, hint }, remaining: 1 } — still no tokens
- POST /v1/auth/login/challenge/verify → accessToken + refresh cookie + device_token cookie; amr: [pwd, device, mfa]
/v1/auth/loginpublicLog in (identifier + password)The first hop is always the DEVICE one: reversing the order would leak the MFA hint (the masked phone) to an unrecognised machine. remaining: 2 tells the UI there is more than one hop to walk.
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 hop 1 (device)Use the token the PREVIOUS hop returned — each verify mints a fresh challengeToken carrying what is LEFT to do, and the old one is spent. Clearing hop 1 returns the next challenge (usually on a different channel), not tokens; show the user which channel using the new hop's hint.
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/login/challenge/verifypublicClear hop 2 (MFA)Clearing the final hop mints the session AND the device_token cookie earned at hop 1. amr accumulates honestly, so a token consumer can tell the four paths apart: [pwd] (scenario 9/10) · [pwd, device] (scenario 11) · [pwd, mfa] (scenario 12) · [pwd, device, mfa] (here). A user suspended mid-chain cannot finish it — the state is re-checked at every hop.
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/login/challenge/resendpublicResend the current hop's codeRe-sends the hop you are ON, not the whole chain, and returns a new challengeToken to continue with.
Field guide — what each value means & where it comes from1 field
Pre-authentication. Re-issues the code for the hop you are currently on — never the whole chain — on the same channel. It SUPERSEDES the live challenge, so the previously delivered code dies; that is why the response carries a NEW challengeToken, which you must use from then on. Throttled per subject and per IP.
challengeTokenbodystringrequiredfrom a responsePOST https://api.kerja.team/v1/auth/login/challenge/resendResponse guide — what comes back & what each value means5 fields
200 with the same challenge-hop shape as §3.79, for the hop you are ON — never the whole chain. Because the live challenge is superseded, the previously delivered code is dead and the challengeToken here REPLACES the one you sent.
data.challengeTokenstringalwaysdata.challenge.typeenumalwaysdevice_email_code— A fresh code was emailed.device_phone_otp— A fresh OTP was sent by phone.mfa_phone_otp— A fresh second-factor OTP was sent by phone.mfa_totp— Nothing to send — a TOTP code comes from the authenticator app, so resend does not apply to this hop.
data.challenge.hintstringalwaysdata.challenge.expiresInnumberalwaysdata.remainingnumberalways