> ## Documentation index
> Fetch the complete documentation index at: https://rolter-ai-rolter.docs7.io/llms.txt
> Ask it with GET https://rolter-ai-rolter.docs7.io/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.

# Two-factor authentication

> TOTP second factor for local accounts, recovery codes, org enforcement policy, and the break-glass path back in.

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](/security/single-sign-on).
</Note>

## 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](/deployment/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.
</Warning>

## Enrolling over the API

```bash
# 1. sign in as normal
TOKEN=$(curl -sS -X POST https://rolter.example.com/api/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"ops@example.com","password":"..."}' | jq -r .token)

# 2. ask for a secret. it is shown exactly once
curl -sS -X POST https://rolter.example.com/api/v1/me/mfa/enroll \
  -H "authorization: Bearer $TOKEN"
```

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.

```bash
# 3. prove a code from the secret. this arms the factor and returns the
#    recovery codes, which are also shown exactly once
curl -sS -X POST https://rolter.example.com/api/v1/me/mfa/confirm \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"code":"123456"}'
```

## 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:

```json
{ "mfa_required": true, "mfa_token": "rolter_mfa_...", "expires_at": "...", "expires_in": 300 }
```

`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:

```bash
curl -sS -X POST https://rolter.example.com/api/v1/auth/mfa/verify \
  -H 'content-type: application/json' \
  -d '{"mfa_token":"rolter_mfa_...","code":"123456"}'
```

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:

```json
{
  "mfa_enrolment_required": true,
  "enrolment_token": "rolter_enrol_...",
  "expires_at": "...",
  "expires_in": 600
}
```

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

```bash
# a secret, shown once: the same `otpauth_uri` and `secret` as /me/mfa/enroll
curl -sS -X POST https://rolter.example.com/api/v1/auth/mfa/enroll \
  -H 'content-type: application/json' \
  -d '{"enrolment_token":"rolter_enrol_..."}'

# prove it: arms the factor and answers with a session and the recovery codes
curl -sS -X POST https://rolter.example.com/api/v1/auth/mfa/confirm \
  -H 'content-type: application/json' \
  -d '{"enrolment_token":"rolter_enrol_...","code":"123456"}'
```

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](#break-glass-a-lost-device) **and a new password**: whoever armed it
knew the password, and the reset alone would let their next sign-in enrol
again.
</Warning>

## 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:

```bash
curl -sS -X POST https://rolter.example.com/api/v1/me/mfa/recovery-codes \
  -H "authorization: Bearer $TOKEN"
```

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:

```bash
curl -sS -X PUT https://rolter.example.com/api/v1/orgs/$ORG/auth-policy \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"allow_password_login":true,"allow_sso":true,"mfa_policy":"required_all"}'
```

### 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):

```bash
curl -sS https://rolter.example.com/api/v1/orgs/$ORG/mfa-status \
  -H "authorization: Bearer $TOKEN"
```

```json
[
  {
    "user_id": "…",
    "email": "ada@example.com",
    "is_superadmin": false,
    "enabled": true,
    "recovery_codes_remaining": 8
  },
  {
    "user_id": "…",
    "email": "bob@example.com",
    "is_superadmin": false,
    "enabled": false,
    "recovery_codes_remaining": 0
  }
]
```

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](#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`:

```bash
curl -sS -X PUT https://rolter.example.com/api/v1/orgs/$ORG/auth-policy \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"allow_password_login":true,"allow_sso":true,
       "mfa_policy":"required_all","mfa_enforce_after":"2026-10-15T09:00:00Z"}'
```

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.
</Note>

## 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:

```bash
rolter mfa reset \
  --database-url "$ROLTER_DATABASE_URL" \
  --email ops@example.com \
  --reason "lost phone, ticket 4211"
```

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.
