Logging in on a new device
Signing inThe password was right, but this browser has never signed in here before, so IAM adds exactly ONE challenge — delivered on the account's primary channel (LN1 phone mode / LN2 email mode). Routed here when DEVICE_TRUST_ENABLED is on and no valid device_token cookie was presented.
- POST /v1/auth/login → 200 with { challengeToken, challenge: { type, hint, expiresIn }, remaining: 1 } and NO tokens
- POST /v1/auth/login/challenge/resend → optional: re-send on the same channel, returns a NEW challengeToken (the previous code is dead)
- POST /v1/auth/login/challenge/verify → tokens + a device_token cookie, so the next login here is scenario 9 / 10
/v1/auth/loginpublicLog in (identifier + password)On an unrecognised browser the answer carries no tokens: challenge.type is device_email_code (email mode) or device_phone_otp (phone mode), and hint is the masked destination. Fail-closed: an unreadable, expired, revoked or foreign device row all count as NEW — a lookup error can only ever cost an extra code, never skip one.
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/resendpublicResend the current hop's codeRe-sends the code for the hop you are ON (never the whole chain) on the same channel. It supersedes the live challenge — the previously delivered code is dead — which is why the response carries a NEW challengeToken. Use that one from here on. 401 on a bad or expired token; throttled per subject and per IP.
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/v1/auth/login/challenge/verifypublicClear a login challenge hopSubmit { challengeToken, code, trustDevice? }. Plan exhausted → accessToken + refresh cookie (amr: [pwd, device]) AND a device_token cookie. trustDevice DEFAULTS TO TRUE — an unticked “remember this device” box would turn every work-laptop login into an OTP, which trains users to click through challenges; send false explicitly on a shared machine (the login still succeeds and still earns amr [pwd, device], it simply mints no cookie). The device secret is delivered ONLY as a cookie — 32 server-minted random bytes of which the database stores only a SHA-256 hash. Trust is per (user, device) and lasts 30 days, sliding; at most DEVICE_TRUST_MAX_PER_USER (20) live devices, and at the cap the least-recently-seen is revoked. Wrong codes are attempt-capped and audited as device_trust_failed — a correct password plus repeated device-challenge failures is the fingerprint of credential stuffing landing on a real account.
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