Skip to main content

A rolter dashboard session reaches every provider credential in the deployment. Local accounts can protect that with a time-based one-time password (TOTP) second factor — the six-digit code an authenticator app generates — on top of the password.

Note

Accounts that sign in through an identity provider inherit whatever MFA that IdP enforces; this page is about rolter’s own local accounts. See Single sign-on.

Requirements#

Enrolment seals the shared secret with the deployment key-encryption key, so ROLTER_KEK must be set on the control plane. Without it, enrolment is refused rather than storing a bearer credential in the clear — see Backup and restore for the sealed-column list, which user_totp_factors is part of.

Optionally, set ROLTER_MFA_ISSUER to the name authenticator apps show beside the account. It defaults to rolter; give each deployment its own so an operator running several can tell the entries apart.

Enrolling from the dashboard#

Settings → Account & keys → Two-factor authentication, the panel after Profile and Password and above your virtual keys.

  1. Set up two-factor authentication shows a QR code and, beside it, the same secret as text for a desktop authenticator or a phone whose camera is not an option.
  2. Type the code the app shows and press Turn on.
  3. Save the ten recovery codes. They are shown exactly once — copy or download them, then tick the acknowledgement. The dialog does not let you past it until you do, because a batch dismissed by a stray click is unrecoverable.

Once the factor is armed the panel offers Regenerate recovery codes and Remove second factor. Both confirm first, and removing one asks for a current code — so a stolen session cannot strip the factor it could not get past. Under a required_* org policy the removal is refused outright and says so.

Warning

The code you confirm with is spent. Signing in immediately afterwards needs the next code, up to 30 seconds later. The dialog says so before you type; it is the replay rule below working, not a fault.

Enrolling over the API#

The response carries an otpauth_uri to render as a QR code and the same secret in base32 for manual entry. Nothing is armed yet: an unconfirmed enrolment grants nothing and does not gate login, so a half-finished enrolment cannot lock anyone out.

Signing in with a factor armed#

In the dashboard this is a second step on the sign-in screen: the password form is replaced by one field that takes either the six digits from your app or a recovery code. A wrong code says how many of the three attempts are left; the third one, or a prompt left sitting past its five minutes, sends you back to the password step rather than leaving you typing into a prompt that can no longer succeed.

Over the API, POST /api/v1/auth/login answers with a challenge instead of a session:

expires_in is the same deadline in seconds from the response. Time a prompt from it rather than comparing expires_at with the local clock: a client whose clock runs a few minutes fast would otherwise expire the prompt the moment it appeared.

Present that token with a code to get the session:

The same endpoint accepts a recovery code in place of a TOTP code; they are told apart by shape, so there is nothing to select.

A challenge lives five minutes and accepts three guesses. Once either runs out, the sign-in starts again from the password — including for the correct code, so a guesser gains nothing by holding a challenge open.

The factor is checked once, at sign-in. The session it issues is an ordinary rolter session: revocable immediately by logging out, and valid for its normal lifetime. rolter does not re-prompt per request.

Setting one up at sign-in#

When your organization requires a second factor and your account has none, the sign-in does not refuse you. After the password it walks you through setting one up, and you get a session once the factor is on:

  1. The card shows a QR code and the same secret as text. Scan it (or type it) into an authenticator app.
  2. Type the code the app shows and press Turn on and sign in. A wrong code keeps you on this step; check the clock on the device and try the next one.
  3. Save the ten recovery codes, tick I have saved these codes, and press Continue to the dashboard. The codes are shown exactly once, so the way in stays shut until you acknowledge them.

The setup prompt lives ten minutes and accepts five codes, and a wrong code says how many are left. If either runs out, or you press Sign in as someone else, the card goes back to the password step and a fresh sign-in starts a fresh setup with a new secret. Delete the entry your app saved from the old one: its codes will never be accepted.

Over the API, the login answers with an enrolment challenge:

The token is good for exactly two calls, and neither takes it as a bearer token:

The confirm answers { "token", "expires_at", "user", "recovery_codes" }. A wrong code is a 400 and uses up one of the five. An expired or spent token is a 401 invalid_credentials, and so is one whose account armed a factor some other way in the meantime, or whose secret another sign-in replaced after the code was checked. Either way, sign in with the password again. The token does not authenticate anything else: presented as a bearer it is refused everywhere, and /auth/mfa/verify does not accept it either.

Warning

Until a member has enrolled, their password is the only thing between an attacker and the account, exactly as with the policy off. Whoever signs in first is the one who enrols. That is the trade every enrol-at-sign-in flow makes. The grace window below lets members enrol from their own session before the requirement applies. Every enrolment at sign-in is audited, and the audit entry says when the password step came straight after a lockout. A factor someone else armed is cleared with the break-glass reset and a new password: whoever armed it knew the password, and the reset alone would let their next sign-in enrol again.

Recovery codes#

Confirming an enrolment issues ten single-use codes. Each works exactly once, in place of a TOTP code, and using one is recorded in the audit log along with how many remain.

Regenerating replaces the whole batch rather than topping it up:

Any code from the previous batch stops working the moment you do this, which is what makes a printed sheet safe to invalidate if it goes missing.

Enforcement policy#

mfa_policy sits on the per-org auth policy alongside the password and SSO switches, and takes one of four values:

ValueMeaning
offthe default; a factor is neither required nor advertised
optionalmembers may enrol; nobody is required to
required_superadminsuperadmins must have an armed factor to sign in
required_allevery member must have an armed factor to sign in

In the dashboard it is the Second factor control on Single sign-on → Sign-in policy, beside the password and SSO switches. Choosing either required_* value confirms before it saves, and so does pulling an announced start date earlier. The confirmation says how many members the organization has, counting each person once however many roles they hold, that anyone without an authenticator app is asked to set one up at their next password sign-in, and links this page’s break-glass procedure. Relaxing the policy saves without a confirmation.

A control plane with no ROLTER_KEK cannot seal a secret, so nobody on it can enrol. It refuses a required_* value with a 409 that names the key, rather than saving a policy that would lock out everyone it binds, the admin saving it included. off and optional still save.

Over the API:

See who a policy would lock out#

Before you choose a required_* value, read who in the org has an armed factor. The dashboard shows the same roster as a Second factor column on Governance › Users (armed or not set up, for admins), and the confirmation for a required_* policy on Single Sign-On → Sign-in policy says how many of the members it binds have none (for required_superadmin, only superadmins are counted):

There is one row per active member, counted once however many roles they hold across the org, its teams and its projects. enabled is an armed factor; a half-finished enrolment is not one. Under required_all every row with enabled: false is sent through enrolment at their next password sign-in, and under required_superadmin it is the rows with is_superadmin: true. recovery_codes_remaining is a count, never a code: 0 on an enrolled member is someone one lost phone away from the break-glass reset. The read needs the same role as reading the policy itself (org admin), and nothing in it is a secret.

A user in several orgs gets the strictest policy across all of them — a relaxed membership never softens a hardened one.

Under a required_* policy, a password sign-in never issues a session to an account with no armed factor: it hands out an enrolment challenge instead (see Setting one up at sign-in). On a control plane with no ROLTER_KEK, which cannot seal a new secret, the sign-in is refused with a 403 mfa_enrolment_required that names the missing key instead, since the fix is the operator’s.

Accepting an invitation goes through the same decision. A new account created by an invitation into an org whose required_* policy is in force gets no session from the link: the screen sends the invitee to sign in with the password they just chose, and that sign-in is where they set up the factor. An invitation for an address that already has an account never signs anyone in at all; it adds the role, and the owner signs in with their own password and factor as usual.

Announcing the requirement first#

An org can give its members notice. Start requiring it, under the Second factor control, offers At their next sign-in or In 7 / 14 / 30 days; over the API it is mfa_enforce_after, a timestamp sent with mfa_policy:

Until that moment an unenrolled member signs in with the password alone. The login response carries mfa_enrol_by with the date, the dashboard says it in a notice on the way in, and Settings → Account & keys → Two-factor authentication shows it too, so members can enrol at their own pace. After it, the next sign-in walks them through enrolment as above.

mfa_enforce_after travels with mfa_policy and is only read when mfa_policy is in the body. There, null means at once, and leaving the key out keeps the stored date, so a client written before the field existed cannot cancel an announced window by re-sending the policy. It is dropped under off and optional, where there is nothing to postpone. A member bound by several orgs is held to the earliest start among them, and a window never lets anyone remove a factor they already have.

Note

Unlike allow_password_login, required_all does not exempt superadmins. It does not need to: the break-glass path runs on the host rather than through the API, so exempting the most privileged account would weaken the policy without buying any recoverability.

Break-glass: a lost device#

A lost phone with no recovery codes left would otherwise be an unrecoverable account — and, for a superadmin, an unrecoverable deployment. The way back in runs on the host, against the database, at the same privilege level as reading the rows it clears:

This clears the factor and its recovery codes, revokes every live session for the account, and drops every sign-in challenge in flight — the reason to run it is that the account is in unknown hands, so leaving a week-long session or a live setup prompt around would leave the factor bypassed anyway. The account can then sign in with its password and enrol again; under a required_* policy that sign-in walks it straight through enrolment.

If the factor being cleared was armed by someone other than the account’s owner, set a new password as well. Whoever armed it knew the password, and under a required_* policy their next sign-in would enrol again. Setting a password or deactivating the account also drops the challenges in flight.

--reason is required, not optional: a break-glass with no stated reason is indistinguishable from an attack when it is read back six months later. It is recorded verbatim on the auth.mfa_break_glass_reset audit entry.

There is deliberately no API endpoint for this. An endpoint that clears a second factor is a second factor that anyone holding a session can clear.

What is audited#

ActionWhen
auth.mfa_enabledan enrolment is confirmed; at_sign_in when it came through the sign-in
auth.mfa_disableda user removes their own factor
auth.mfa_confirm_faileda wrong code during enrolment
auth.mfa_disable_faileda wrong code when removing a factor
auth.mfa_faileda wrong code at sign-in, with the attempt number
auth.mfa_recovery_code_useda recovery code is spent, with the count left
auth.mfa_recovery_codes_regenerateda batch is replaced
auth.mfa_enrolment_challengea policy sent an unenrolled account to enrol at sign-in; after_lock says whether the password step followed a lockout
auth.mfa_enrolment_requireda policy refused an unenrolled account because the control plane has no ROLTER_KEK
auth.mfa_break_glass_resetrolter mfa reset, with the stated reason

Not covered#

WebAuthn and passkeys. They are the stronger factor and are tracked separately; TOTP needs no browser API work and is the factor every security review asks for.