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. 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 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 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.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.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
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_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.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_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.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).
  • true — MFA 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.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.
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_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.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.
  • true — 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 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 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.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_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.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
On completion: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). On a sessionless completion: the lifetime of the bootstrapToken / acceptToken.
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. Never set on the sessionless branches.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.