Skip to content

Login methods

The hosted sign-in screens are what /authorize redirects to. They offer four first factors, all converging on one completion path, with passkeys and TOTP layered on top.

POST /login/email/start
Content-Type: application/json
{ "email": "ada@acme.com", "turnstile_token": "" }
{ "ok": true }

The response is { "ok": true } whether or not the address belongs to a user. Enumeration-safety is the point: a login form that says “no such user” is a user-directory lookup for anyone who asks.

POST /login/email/verify
{ "email": "ada@acme.com", "code": "123456", "flow": "<flow id>" }

Codes are hashed at rest and valid for 10 minutes. A code is burned after 5 wrong guesses, so the six-digit space cannot be searched within its lifetime, and a cumulative 15 failures against one address blocks further attempts regardless of how many fresh codes are requested. amr becomes ["otp"].

POST /login/link/start
{ "email": "ada@acme.com", "flow": "<flow id>" }

The emailed link is a GET to /login/link, which deliberately does not consume the token — the screen it opens does, by POSTing to /login/link/verify. That split exists because mail scanners and link-preview bots follow URLs in email, and a token consumed by a scanner is a token the user cannot use.

Tokens are hashed at rest, single-use, and valid for 15 minutes. amr becomes ["link"].

POST /login/password
{ "email": "ada@acme.com", "password": "", "flow": "<flow id>" }

Hashing is PBKDF2-SHA256 on WebCrypto, with the iteration count stored per hash so it can be raised over time without invalidating existing passwords.

Policy is checked on set: minimum length, a common-password list, and no containment of the user’s own email address. A rejected password returns 400 weak_password with the reason.

Failures are capped per email address — ten attempts — as well as per IP. The per-email lockout engages first and surfaces as invalid_credentials, which is why the password rate-limit bucket is deliberately roomier than the email one: an attacker grinding one account should hit the account lockout, not a generic IP throttle that tells them nothing.

amr becomes ["pwd"].

POST /login/password/change # session required
{ "current_password": "", "new_password": "" }
POST /login/password/reset/start # enumeration-safe
{ "email": "ada@acme.com" }
POST /login/password/reset/confirm
{ "token": "", "new_password": "" }

The reset flow doubles as first-time password set for users provisioned by SCIM or SSO, who have no password yet. Reset tokens are valid for 30 minutes.

GET /login/social # which providers are configured
GET /login/social/:provider/start
GET /login/social/:provider/callback

Google, Microsoft, and GitHub, configured at the platform level. The brokered leg uses PKCE and a nonce.

Accounts link on verified email and are recorded in a persistent (provider, subject) identity map, so a user who signs in with Google and later with a password reaches the same account, and a provider that changes a user’s email address does not orphan it.

Unknown emails are provisioned just-in-time — except throwaway-inbox domains, which are refused at signup. amr becomes ["sso"].

Every first factor finishes the same way, which is what makes MFA uniform rather than per-method.

If the user has no active TOTP enrollment, the login completes: a session is created, a login_success audit event is written, and — when the login was resuming an /authorize flow — the response carries a continue URL to the consent screen.

{ "ok": true, "user_id": "usr_01J…", "continue": "/u/consent?flow=…" }

If the user does have TOTP active, no session is created. Instead a short-lived, attempt-capped mfa_token is returned, and only /login/mfa/verify can finish the login:

{ "ok": true, "mfa_required": true, "mfa_token": "mfa_…" }

Because this is one path rather than four, adding a second factor covers every first factor at once — there is no method that quietly bypasses it.

GET /login/branding

Returns the tenant’s display name, logo, and primary colour, resolved from the request host. The hosted screens use it so a customer’s users see their own employer at sign-in rather than yours. Set it with PATCH /v1/organizations/:orgId.

Each step writes an audit event: login_code_sent, login_link_sent, login_success, social_login, password_set, password_changed, password_reset_requested, password_reset. Blocked and challenged attempts write login_blocked and login_challenged — see bot and abuse protection.

After a successful login, anomaly signals are recorded as login_anomaly without gating the login: a first-ever device, or travel from the previous login’s location that is not physically possible. Those are yours to act on.