switching clears the token & responses
No token
11

Logging in on a new device

Signing in

The 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.

  1. POST /v1/auth/login → 200 with { challengeToken, challenge: { type, hint, expiresIn }, remaining: 1 } and NO tokens
  2. POST /v1/auth/login/challenge/resend → optional: re-send on the same channel, returns a NEW challengeToken (the previous code is dead)
  3. POST /v1/auth/login/challenge/verify → tokens + a device_token cookie, so the next login here is scenario 9 / 10
POST/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.

⛓ 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. 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 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 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.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.
  • BearerSend the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Non-MFA branch only. Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Client should refresh before it elapses.
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_codeNew browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otpNew browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otpAn enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totpAn 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_otpSend/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totpRead 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).
  • trueMFA 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.
POST/v1/auth/login/challenge/resendpublicResend the current hop's code

Re-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.

Request body (JSON)
ℹ︎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 response
The token for the hop you want re-sent, from POST /v1/auth/login or the previous verify. A bad or expired token → 401. The new challengeToken in the response replaces this one for every subsequent call.
POST https://api.kerja.team/v1/auth/login/challenge/resend
↩︎Response 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.challengeTokenstringalways
The new token for this hop. Use it for the subsequent verify — the token you passed in is spent.
data.challenge.typeenumalways
The hop's factor, unchanged by the resend (the code goes out on the same channel).
  • device_email_codeA fresh code was emailed.
  • device_phone_otpA fresh OTP was sent by phone.
  • mfa_phone_otpA fresh second-factor OTP was sent by phone.
  • mfa_totpNothing to send — a TOTP code comes from the authenticator app, so resend does not apply to this hop.
data.challenge.hintstringalways
The masked destination the fresh code went to.
data.challenge.expiresInnumberalways
Seconds until the new code expires.
data.remainingnumberalways
Hops still to clear, unchanged by a resend.
POST/v1/auth/login/challenge/verifypublicClear a login challenge hop

Submit { 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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 response
The token POST /v1/auth/login — or the PREVIOUS challenge/verify hop — just returned. Copy it from that response; each verify mints a fresh one carrying what is LEFT to do and the old one is spent, so in a two-hop chain (scenario 13) hop 2 must use the token hop 1 returned. It carries the ordered remaining plan and the accumulated amr, which is why the server keeps no per-chain state. At most 4096 chars; expires with LOGIN_CHALLENGE_TTL_SECONDS (default 300s) — if it lapses, restart at login.
codebodystringrequiredout-of-band
The code for the hop you are on: an email code (device_email_code), a phone OTP (device_phone_otp / mfa_phone_otp), a TOTP from the authenticator app (mfa_totp), or a single-use recovery code. Use the “Get a pending login-challenge code” helper for everything except mfa_totp, whose code is computed from the enrolled secret (use the TOTP helper). At most 64 chars. Wrong codes are attempt-capped and audited — device_trust_failed on a device hop, login_challenge_failed otherwise; exhausting the cap consumes the challenge and forces a resend.
trustDevicebodybooleanoptionalyou choose
Whether to remember this browser. Has effect ONLY on a device_* hop — clearing an MFA hop never earns device trust, or a phished MFA code would silence the new-device control on the attacker's machine.
  • trueTHE 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.
  • falseSend 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 choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
POST https://api.kerja.team/v1/auth/login/challenge/verify
↩︎Response 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.challengeTokenstringconditional
Present only while hops remain. A fresh token carrying the ordered REMAINING plan and the accumulated amr — which is how the server keeps no per-chain state. Use this one for the next verify or resend; the previous token is dead.
data.challenge.typeenumconditional
Which factor the next hop collects. Device hops always come BEFORE MFA hops — reversing them would leak the MFA hint (the masked phone) to an unrecognised machine.
  • device_email_codeA code was emailed — this is an email-mode account on a new device (LN2).
  • device_phone_otpAn 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_otpThe phone-OTP second factor; the OTP is sent as the challenge is issued.
  • mfa_totpThe 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.hintstringconditional
The masked destination for this hop (b••••@example.com, +••••••6789). In a two-hop chain the hops usually land on DIFFERENT channels, so the UI should say which, using each hop's own hint.
data.challenge.expiresInnumberconditional
Seconds until this hop expires (LOGIN_CHALLENGE_TTL_SECONDS, default 300). If it lapses, restart at login.
data.remainingnumberconditional
How many hops are still to clear, counting this one: 2 on the first hop of LN3, 1 on the last. Present only while hops remain.
data.accessTokenstringconditional
Present only when the plan is exhausted. The bearer token for the tenant surface; auto-captured by this tester as the active token.
data.tokenTypestringconditional
Always "Bearer" when a token is issued.
data.expiresInnumberconditional
Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600).
data.userobjectconditional
The signed-in user (id, email / phone, displayName, tenantId) — present only on completion.
Set-Cookie: refresh_tokencookieconditional
The rotating refresh token, HttpOnly, issued on completion.
Set-Cookie: device_tokencookieconditional
Set only when a device hop was cleared with trustDevice (the default). 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 — a cookie earned by one user never silences the challenge for another on the same laptop.