switching clears the token & responses
No token
8

Recognising a returning browser

Signing in

Before any credential is typed, the login screen asks whether THIS browser has signed in here before, so it can render “Welcome back, Budi” instead of a blank form. Behind DEVICE_TRUST_ENABLED; always { known: false } when off.

  1. GET /v1/auth/device → { known } (+ displayName, maskedIdentifier, loginMode, and the usable-credential set methods with maskedEmail / maskedPhone when recognised)
  2. POST /v1/auth/login → render the form the answer implies and continue at scenario 9 / 10 — on a recognised device the body needs only { password }
GET/v1/auth/devicepublicIs this browser recognised?

Reads the HttpOnly device_token cookie SERVER-SIDE — the cookie value never reaches script (HttpOnly; Secure; SameSite=Lax; Path=/v1/auth), and this endpoint is the only place its meaning is exposed, as a MASKED identifier and never a working one. SameSite=Lax (not Strict like the refresh cookie) so a top-level navigation from an emailed or WhatsApp link still presents it. A recognised device also returns methods — the account's USABLE credential set, a channel listed only when its credential is verified — plus a maskedEmail / maskedPhone per method, so the screen opens on a method the user can actually complete and keeps a mask on screen across an email↔phone switch. THE MASK STAYS A MASK: the client never needs the real value, because POST /v1/auth/login resolves the account from the same cookie and makes identifier optional. OFFER A WAY OUT — known: true is a hint, not a claim about who is sitting there, so the screen must carry a “Not you? / Sign in as someone else” affordance or a shared machine strands the second person. No oracle: no cookie, an expired one and a revoked one all return the identical { known: false }. known: true is NOT authentication and grants nothing — a device token identifies a device, never a user. Cache-Control: no-store, throttled per IP. The browser sends the cookie automatically once a login has set it (scenario 11).

⛓ Needs an out-of-band value?
ℹ︎Field guide — what each value means & where it comes from1 field

Public and unauthenticated — and it takes no inputs at all. The one value it reads, the device_token cookie, is HttpOnly and travels automatically: the browser attaches it because this tester sends credentials: "include" and the cookie's Path is /v1/auth. You cannot type it, read it from script, or forge it here; it is set only by completing a new-device login challenge (scenario 11). Cache-Control: no-store, throttled per IP.

device_tokenheadercookieoptionalauto-captured
The device secret, sent automatically by the browser when one exists for this origin. HttpOnly; Secure; SameSite=Lax; Path=/v1/auth — SameSite=Lax rather than Strict so a top-level navigation from an emailed or WhatsApp link still presents it, otherwise every click-through from an inbox would look like a new device. No cookie, an expired one, and a revoked one are indistinguishable in the response (all { known: false }).
GET https://api.kerja.team/v1/auth/device
↩︎Response guide — what comes back & what each value means7 fields

Always 200 with the { data } envelope; Cache-Control: no-store. NO ORACLE: no cookie, an expired cookie, a revoked one, and DEVICE_TRUST_ENABLED=false all produce the identical { known: false }. When the device IS recognised the extra fields are hints only — a display name and a MASKED identifier, never a working one, so a stolen laptop does not hand over a usable address.

data.knownbooleanalways
Whether this browser has completed a verification for an account before.
  • trueRender “Welcome back” with the hints below and the channel-appropriate form. This is NOT authentication and grants nothing — a device token identifies a DEVICE, never a user, and is only ever accepted alongside a verified password.
  • falseRender the generic sign-in form. Says nothing about why: no cookie, expired, revoked, and feature-off are indistinguishable.
data.displayNamestringconditional
The account's display name, present only when known is true — the “Budi” in “Welcome back, Budi”.
data.maskedIdentifierstringconditional
The account's masked email or phone (b••••@example.com, +••••••6789), present only when known is true. Never the full value. Possessing a valid cookie already proves this browser completed a verification, so the hint tells the holder nothing new. The mask STAYS a mask: the client cannot complete the following login by echoing it back — it does not have to, because POST /v1/auth/login resolves the account from the same cookie and makes identifier optional. This is the hint for the loginMode channel alone; maskedEmail / maskedPhone below describe each channel independently.
data.loginModeenumconditional
The account's primary channel, present only when known is true — lets the UI render the right form before a credential is typed.
  • emailPre-fill the email form; a new-device challenge for this account would arrive as an email code.
  • phonePre-fill the phone form; a new-device challenge would arrive as a phone OTP.
data.methods[]arrayconditional
The account's USABLE credential set, present only when known is true — so the sign-in screen opens on a method the user can actually complete and never offers a form they cannot (BR-6). A channel is listed ONLY when its credential is verified (email_verified_at / phone_verified_at), the same gate PATCH /v1/me applies before switching loginMode. Note this is the account's capability, not the platform's: cross-check it against the global login policy (scenario 7), which can close a channel this account still holds.
  • emailThe account holds a VERIFIED email — offer the email form and switch to it with maskedEmail on screen.
  • phoneThe account holds a VERIFIED phone — offer the phone form (password or one-time code) with maskedPhone on screen.
data.maskedEmailstringconditional
Masked hint for the email channel, present only when known is true AND methods includes email. Exists so the screen keeps a mask on display across an email↔phone switch, rather than blanking when the user changes method.
data.maskedPhonestringconditional
Masked hint for the phone channel, present only when known is true AND methods includes phone. The counterpart of maskedEmail — and the reason login and phone/otp/request both accept a missing identifier on a recognised device: the UI has only ever seen +••••••6789 and must not have to ask the user to re-type it.
POST/v1/auth/loginpublicLog in (identifier + password)

identifier is an email OR an E.164 phone — the shape decides the lookup ({ email } is still accepted as a deprecated alias) — and it is OPTIONAL on a recognised device, where the device_token cookie names the account and the body needs only { password }. Three possible answers: { accessToken, … } (nothing left to prove — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — continue at scenario 11/12/13), or the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12). Gated by the LOGIN policy before the credential is even checked → 403 login_method_disabled (scenario 7).

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