# Tenant two-factor authentication

Tenant TOTP enrollment belongs to an organization's member and applies across
that organization's applications. It is separate from
[consumer two-factor settings](https://m7.org/docs/api/sso.user.m7.org/two-factor-authentication.md), even for a
consumer-linked tenant member. A consumer factor does not substitute for the
tenant factor, and changing a tenant factor does not reset the consumer one.

## When a factor is required

| Authentication path | Local tenant factor |
| --- | --- |
| Hosted tenant password sign-in | Required when main org allowance and member enrollment are enabled |
| Browser-direct password sign-in | Same, including consumer-linked password authentication in tenant mode |
| Password-based tenant Device Code approval | Uses tenant **password** policy; no separate public tenant Device Code preference |
| Browser-direct email code / magic link | Main allowance, email allowance, enabled factor and saved `email_logins` must all be true |
| Fresh brokered/federated authentication | **Never requires local tenant 2FA. The broker owns that responsibility.** |
| Existing authenticated session, refresh or remembered-account switch | No new local tenant-factor prompt; normal session/member/app admission still applies |

The exemption follows verified brokered authentication, not a caller's skip flag,
and does not claim the broker performed MFA. Password setup/change/removal and
forgot-password recovery do not erase factors or saved email preferences. A new
password sign-in afterward still follows the current matrix. This tenant matrix
does not import consumer Device Code or account-switch interval settings.

## Availability and retained enrollment

The org-only `tenant_policy_allow_two_factor` and
`tenant_policy_allow_email_two_factor` default off without consumer inheritance
or app overrides. Main off prevents local enrollment, use and member factor
changes while retaining enrolled authenticators, backup codes and preferences.
Turning it back on restores applicable use. Email allowance off preserves the
saved email preference and makes its effective use unavailable. Managers can
still reset retained factors. There is **no require-enrollment mode**.

Policy is rechecked during pending operations and before session creation. An
unavailable policy store is an error, not permission to treat factors as off.
See [org policy and configuration APIs](https://m7.org/docs/api/api.user.m7.org/tenant-policy).

## Browser-direct endpoint reference

All are SSO POSTs below the [browser-direct base](https://m7.org/docs/api/sso.user.m7.org/browser-direct.md), JSON with
fresh endpoint-bound DPoP. Management calls require selected session handles,
current activated-token/member admission and no extra OAuth scope. Setup, begin
and confirm require an actual primary sign-in within five minutes; status does
not. Proof is bound to org/member, app, origin, key, carrier, selected session,
purpose, exact factor and policy revision. A different selected session cannot
finish the challenge. Use a fresh same-carrier sign-in when required.

| Relative path | Exact additional input | Success |
| --- | --- | --- |
| `/two-factor/status` | None | Status projection below |
| `/two-factor/setup` | Optional `name`, default `Authenticator` | One-time `totp_secret` and `challenge` |
| `/two-factor/begin` | `purpose: policy`, `recovery` or `unlink`; `policy` only for policy purpose | `required: true`, `challenge`, `recovery_codes_remaining` |
| `/two-factor/confirm` | `purpose: setup`, `policy`, `recovery` or `unlink`; copied `challenge`, `code`, optional boolean `recovery` (default false); exact `policy` again for policy purpose | `verified: true`; setup/recovery adds ten new `recovery_codes`; unlink adds `removed: true` |
| `/two-factor/login` | **No session handles:** `continuation`, `code`, optional boolean `recovery` (default false) | New one-use authorization `code`, `state`, `expires_in: 120`, `two_factor_disabled` |

`name` is trimmed, nonempty, at most 191 bytes and contains no controls.
`code` is a current six-digit TOTP string, or an unused backup code when
`recovery: true` (input at most 64 bytes). Setup requires TOTP, not recovery.
`policy` must be exactly `{"email_logins":true}` or
`{"email_logins":false}`; other tenant policy fields are not public settings.
Policy cannot be submitted for other purposes. A confirmation must repeat the
same desired boolean used at begin.

Status has `available`, `email_available`, `configured_enabled`, `email_enabled`,
`enrolled`, `enabled`, `authenticator` (null or `{id,name,type,verified_at}`),
`policy: {email_logins}`, and `recovery_codes_remaining`. `configured_enabled`
and saved `policy` describe retained configuration; `enabled` and `email_enabled`
describe effective enforcement under current org allowances. Never infer that
an unavailable factor or saved preference has been deleted. Ordinary status
contains no seed, challenge binding, backup plaintext or private proof.

```json
{
  "available":true,"email_available":true,"configured_enabled":true,
  "enrolled":true,"enabled":true,"email_enabled":false,
  "authenticator":{"id":"ffffffff-ffff-4fff-8fff-ffffffffffff","name":"Authenticator","type":"totp","verified_at":"2026-01-01 00:00:00"},
  "policy":{"email_logins":false},"recovery_codes_remaining":10
}
```

## Enrollment, QR and backup codes

Call setup and retain its returned challenge object (`id`, `binding`,
`authenticator_id`, `expires`, a UTC `YYYY-MM-DD HH:MM:SS` string). Show the
Base32 `totp_secret` for manual entry, or generate an `otpauth://totp/` QR locally
using SHA1, six digits, 30-second period, a clear account label and matching issuer
prefix/parameter. Never send the seed to an external QR service. Scanning is
not enrollment: confirm with `purpose: setup` and a current code.

```json
{
  "bd_carrier":"<carrier>","bd_session":"<selected session>","bd_key":"<carrier secret>",
  "purpose":"setup",
  "challenge":{"id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","binding":"<one-use binding>","authenticator_id":"ffffffff-ffff-4fff-8fff-ffffffffffff","expires":"2026-01-01 00:10:00"},
  "code":"123456"
}
```

Generate dates and codes at runtime; the example is illustrative. Successful
tenant enrollment immediately enables its applicable password checks and shows
ten backup codes **once**. Save them privately, then clear setup/QR/backup material
from the UI. There is one linked factor per member. New setup replaces abandoned
pending enrollment, not a linked factor. Setup and its challenge expire after
ten minutes. There is no remote cancel endpoint; cancel clears local material,
and pending setup expires or is replaced by a subsequent setup.

Replace backup codes with begin/confirm purpose `recovery`, proved by TOTP or
an unused backup code. The returned ten-code set invalidates the old set. A lost
response cannot redisplay plaintext: inspect status, then authorize another
replacement with a valid remaining proof. Remove a factor with purpose `unlink`
and valid proof; replacement is removal followed by new setup/confirmation, not
a dedicated atomic replace operation. Removal clears factor policy, backup codes
and outstanding challenges. Lost all proofs requires authorized
[manager reset](https://m7.org/docs/api/api.user.m7.org/tenant-member-security).

## Login continuation, limits and retries

Browser-direct token pickup can return `outcome: two_factor_required` and a
five-minute continuation. No usable new token/session exists until proof and
final admission succeed. `/two-factor/login` must use the original browser key
and bound context. On success redeem its new code with the original PKCE verifier;
see [token pickup](https://m7.org/docs/api/sso.user.m7.org/browser-direct-authentication.md#token-pickup-and-two-factor-continuation).
Hosted and Device Code screens preserve their own original authorization/grant
context and do not let a pending factor authorize a session or device pickup.

Ordinary login/management challenges expire after five minutes. There are five
failed attempts per challenge, ten failed attempts and twenty created challenges
per member in fifteen minutes. Restarting the dialog does not reset that budget.
TOTP uses adjacent time steps with a persistent replay check: a code just used
for enrollment or another operation cannot immediately be reused. Wait for the
next code. Proof consumption and local changes are transactional; concurrent
proofs do not both win. Factor/policy changes invalidate stale authority.

Backup codes are single-use. Consuming the **last backup code during login**
retires the factor, clears its settings/codes/challenges, permits that login,
and returns `two_factor_disabled: true`. Offer fresh enrollment. This forced
last-code behavior is not a general rule for every management operation and
does not add the consumer UI's optional disable checkbox to the tenant API.

`two_factor_invalid` (400) covers rejected/expired/replayed proof;
`two_factor_rate_limited` (429) requires waiting fifteen minutes.
`two_factor_not_allowed` / `email_two_factor_not_allowed` (403) require current
policy/status reload. `authenticator_already_linked`, `authenticator_unavailable`,
`tenant_member_unavailable` or `two_factor_required` (409) require resolving
changed state. `reauthentication_required` (403) needs primary sign-in again.
Malformed fields are `invalid_request`. Operational failure never means “factor
off.” After lost management confirmation inspect status before starting another
operation; a consumed login continuation requires new sign-in.

## Operator prerequisites

Tenant factor, challenge, backup-code and factor-policy storage is automatically
ensured using the service's existing database conventions, along with org policy
storage. The runtime needs permission to ensure missing tables, or a separately
authorized installer must create them first. Auto-ensure does not promise to
rewrite an incompatible existing schema. No destructive reset is required to
enable this feature.

Enrollment/TOTP use requires the existing external encryption keyring configured
by `M7_TWO_FACTOR_KEYRING_FILE` and readable only by the appropriate service
identities. Preserve old key versions while enrollments reference them. API/SSO
must share compatible configuration; never expose key values, seeds or backup
codes in diagnostics. Unenrolled paths and authorized administrative clearing
do not require decrypting a seed. Reuse the deployed email service for optional
email sign-in. Configuration checks, local protocol tests and real-user/live
acceptance are separate evidence.
