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.
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.
- 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.
- Type the code the app shows and press Turn on.
- 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.
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:
- The card shows a QR code and the same secret as text. Scan it (or type it) into an authenticator app.
- 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.
- 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.
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:
| Value | Meaning |
|---|---|
off | the default; a factor is neither required nor advertised |
optional | members may enrol; nobody is required to |
required_superadmin | superadmins must have an armed factor to sign in |
required_all | every 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.
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#
| Action | When |
|---|---|
auth.mfa_enabled | an enrolment is confirmed; at_sign_in when it came through the sign-in |
auth.mfa_disabled | a user removes their own factor |
auth.mfa_confirm_failed | a wrong code during enrolment |
auth.mfa_disable_failed | a wrong code when removing a factor |
auth.mfa_failed | a wrong code at sign-in, with the attempt number |
auth.mfa_recovery_code_used | a recovery code is spent, with the count left |
auth.mfa_recovery_codes_regenerated | a batch is replaced |
auth.mfa_enrolment_challenge | a policy sent an unenrolled account to enrol at sign-in; after_lock says whether the password step followed a lockout |
auth.mfa_enrolment_required | a policy refused an unenrolled account because the control plane has no ROLTER_KEK |
auth.mfa_break_glass_reset | rolter 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.