switching clears the token & responses
No token
12

Logging in with a second factor

Signing in

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

  1. 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)
  2. Current shape · POST /v1/auth/login/challenge/verify → accessToken + refresh cookie (amr: [pwd, mfa])
  3. Legacy shape (DEVICE_TRUST_ENABLED off) · login → { mfaRequired, method, phoneHint, mfaToken }, then mfa/otp/request (phone_otp only), then mfa/verify
POST/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.

⛓ 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/verifypublicClear a login challenge hop

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

⛓ 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.
POST/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.

Request body (JSON)
ℹ︎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 response
The single-purpose JWT returned by POST /v1/auth/login when it responds { mfaRequired: true, method: "phone_otp", phoneHint, mfaToken }. The user does not type this — copy it from the prior login response in the tester. It identifies the live phone_otp challenge so IAM can send (or resend) the SMS/WhatsApp OTP. Validation: 1–4096 chars.
POST https://api.kerja.team/v1/auth/mfa/otp/request
↩︎Response 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.statusstringalways
Fixed confirmation that the OTP send/resend was accepted and the outbox event (otp.requested, purpose=mfa) was queued for the external notifier. The client simply proceeds to collect the code from the user and call POST /v1/auth/mfa/verify.
  • acceptedThe OTP request was accepted for delivery over SMS or WhatsApp (channel chosen downstream). This is the only possible value.
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 response
The short-lived JWT returned by POST /v1/auth/login (§3.6) in the mfaRequired branch as data.mfaToken. The user does not type it — the tester copies it from the prior login response. Validation: 1–4096 chars.
codebodystringrequiredauthenticator
The second factor the user enters: a current TOTP code from their authenticator app, a single-use recovery code, or the phone OTP sent by §3.7 (mfa purpose). Method-agnostic (IAM-MFA-05) — IAM validates it against whichever factor the account holds. Validation: 1–32 chars. A correct recovery code is consumed (single-use).
POST https://api.kerja.team/v1/auth/mfa/verify
↩︎Response 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.accessTokenstringalways
The bearer JWT access token for the now-authenticated session. The client sends it as Authorization: Bearer <token> on subsequent calls. Session amr is ["pwd","mfa"].
data.tokenTypestringalways
Token scheme to use in the Authorization header. Always Bearer.
  • BearerSend the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberalways
Lifetime of the access token in seconds (e.g. 600 = 10 minutes). The client should refresh via §3.9 before it expires.
data.user.idstringalways
The authenticated user's unique identifier (UUID).
data.user.emailstringalways
The authenticated user's email address.
data.user.displayNamestringalways
The authenticated user's human-readable display name, for UI presentation.
data.user.tenantIdstringalways
The tenant (organization) the user is authenticated into. Scopes all subsequent tenant-bound requests.