Two-factor authentication for platform accounts
Console accounts can add a second factor: a TOTP authenticator app plus ten one-time recovery codes. Organisations and the instance can require it. This page covers what users, organisation owners and operators see, and the API behind it.
For users
Account → Security turns it on:
- Set up two-factor authentication asks you to confirm it is you (password, or a code if you already have one) when your session is older than 10 minutes.
- Scan the QR code with any authenticator app (1Password, Google Authenticator, Authy, …) or type the setup key. The parameters are fixed: SHA-1, 6 digits, 30 seconds.
- Type the first code. You get 10 recovery codes, shown once: download or copy them. Each signs you in once.
- Your other browser sessions are signed out. If you have API tokens the page offers to revoke them all, since tokens are not protected by the second factor.
Signing in then takes two steps: the password, then a code (or Use a recovery code). After a recovery code the console opens Account → Security and says how many are left; New recovery codes replaces all ten.
Turn off needs your password and a code. It is refused while an organisation you belong to, or the instance, requires two-factor authentication.
Lost your device and your codes? Ask an instance owner to reset it (see below). Email password reset does not clear it.
Confirm it's you
Creating an API token, approving a CLI device login, enrolling, turning 2FA off, generating new recovery codes, making someone an organisation owner, requiring 2FA for an organisation or the instance, deleting or transferring an organisation (instance admin) and resetting another user's 2FA need a password or code from the last 10 minutes of the session. A fresh sign-in counts. When the window has passed the console asks once (code for 2FA users, password otherwise) and retries the action.
For organisation owners
Settings → Security → Require 2FA (owners only):
- Members get 7 days. During that time they sign in normally and every console page shows a reminder; owners and admins see who has not enrolled yet.
- After 7 days, a member without a factor who signs in with the right password is taken straight to enrolment. Nothing else in the console is reachable until the first code is confirmed.
- Someone in several organisations follows the strictest one.
- Existing sessions are not signed out when the requirement is turned on; it applies at the next sign-in (sessions last at most 30 days).
For operators
Instance → Security → Require 2FA for owners (
requireMfaForInstanceOwners, an instance setting, live, default off): every instance owner needs a factor at the next sign-in, with no grace period. Only an owner who already has 2FA can turn it on. It cannot be set throughPUT /v1/instance/limits.Instance → Users → user → Reset 2FA with a reason clears the factor and recovery codes and signs the user out everywhere, including uncollected CLI device approvals. API tokens stay. Written to the instance audit log and to the audit log of every organisation the user belongs to. Your own account is refused here.
Offline, with the server stopped, for when the only instance owner has lost device and codes:
bashtotis-workers admin reset-mfa --email [email protected] totis-workers admin reset-password --email [email protected] --clear-mfaBoth write
recovery.reset_mfato the audit log withip = host-cli.First run: after setup creates the first owner, the setup page offers enrolment straight away.
Security properties
- The TOTP secret is sealed in the vault under the system organisation's key (instance KEK), in its own
userscope that is never injected into any app. Only the enrolment answer carries it, once.db_systemholds no reversible secret: recovery codes and challenges are SHA-256 hashes, and the system-database browser maskscode_hash. - A code's 30-second step is accepted once per user, with ±1 step of clock skew; concurrent submissions of one code let exactly one through.
- The password step never sets a cookie for an account with a factor. It returns a challenge (5 minutes, 5 attempts) that only
POST /v1/auth/mfaaccepts. - Code attempts are rate-limited (30 per IP and 10 per account per 15 minutes). After 10 wrong codes the code step is locked for 15 minutes and
mfa.lockoutis audited. The lockout is only reported after a correct password, so it cannot be triggered without the password. - Accepting an invitation with the password of an existing account that has 2FA is refused: sign in first, then open the link.
- Audit actions:
login.failed,mfa.enable,mfa.disable,mfa.failed,mfa.lockout,mfa.recovery.used,mfa.recovery.regenerate,reauth.failed,token.revoke_all,org.security.set,instance.user.mfa-reset,instance.mfa,recovery.reset_mfa. None carries a code, secret, URI or challenge.
API
All routes are same-origin and browser-session only.
| Route | Does |
|---|---|
POST /v1/login | {ok} with a cookie, {mfa: "required", challenge} or {mfa: "enrol_required", challenge} (no cookie) |
POST /v1/auth/mfa {challenge, code | recoveryCode} | second step; sets the session cookie; recoveryCodesLeft after a recovery code |
POST /v1/auth/mfa/enrol {challenge} | enrol-required sign-in: start enrolment, returns {uri, secret} |
POST /v1/auth/mfa/enrol/confirm {challenge, code} | confirm the first code; sets the cookie, returns recoveryCodes |
POST /v1/auth/reauth {password} | {code | recoveryCode} | refresh the 10-minute window |
GET /v1/account/mfa | status, codes left, active tokens, which policy requires it |
POST /v1/account/mfa/totp | start enrolment: {uri, secret} |
POST /v1/account/mfa/totp/confirm {code} | finish enrolment: {recoveryCodes} |
POST /v1/account/mfa/disable {password, code | recoveryCode} | turn off |
POST /v1/account/mfa/recovery-codes {code | recoveryCode} | replace all ten codes |
POST /v1/account/tokens/revoke | revoke all of your API tokens |
GET/PUT /v1/orgs/{org}/security {requireMfa} | org requirement (PUT: owners) |
GET/PUT /v1/instance/mfa {requireMfaForInstanceOwners, reason} | instance requirement |
POST /v1/instance/users/{id}/mfa-reset {reason} | admin reset |
A guarded action without a recent password or code answers 403 {reauth: true, method: "code" | "password"}.
Not supported: passkeys/WebAuthn, SMS or email codes, 2FA on API tokens or the CLI, "remember this device", self-service recovery by email.