# Two-factor authentication

Manage the signed-in consumer account's authenticator, sign-in settings, and
recovery codes. The [M7 two-factor settings page](https://user.m7.org/members/settings/2fa)
provides enrollment with a QR code or manual setup key, a unified settings
editor, recovery-code generation, and authenticator unlinking.

## Authorization

Use an accepted consumer access token under the
[API User authorization profile](https://m7.org/docs/api/api.user.m7.org/authorization.md). An unbound token uses
`Authorization: Bearer ACCESS_TOKEN`; a bound token requires the corresponding
DPoP, fingerprint, or certificate proof. Machine and organization-member
credentials cannot manage these settings.

The account comes from the authenticated token. Do not send `uid`, `tenant`,
`owner`, or `user_id`: these fields are rejected. An `authenticator_id` must
belong to the caller. A linked authenticator is required for settings changes,
recovery-code generation, and unlinking; only one authenticator can be linked
at a time.

Every route below is `POST`, accepts JSON, and uses the base URL
`https://api.user.m7.org/api/v2/account/me/two_factor`. Its configured route scope
is `account.me.two_factor.` followed by the route suffix with `/` changed to
`.`; for example, `account.me.two_factor.policy_batch.confirm`. The service's
existing token and route-policy rules still apply; a scope alone never grants
access to another account.

## Routes

In this table, **proof** means `authenticator_id`, `challenge_id`,
`challenge_binding`, and a current six-digit string `totp_code`.

| Route suffix | Required input | Optional input or behavior | Success comment and useful `data` |
| --- | --- | --- | --- |
| `/status` | None; send `{}` | No pagination | `OK`: `factors`, `policy`, `recovery`, `federated_unlink` |
| `/begin` | `name`: nonempty string, at most 191 bytes after trimming | Starts pending enrollment | `BEGIN`: `factor`, `totp_secret`, `challenge` |
| `/confirm` | proof | `enable_immediately`: JSON boolean, default `false` | `CONFIRMED`: `confirmed: true`, `factor` |
| `/policy_batch/begin` | `authenticator_id`, complete `policy` object | Preferred unified settings operation | `BEGIN`: `factor`, `desired`, `challenge` |
| `/policy_batch/confirm` | proof, the identical complete `policy` | All settings change together | `UPDATED`: `updated: true`, `factor`, `policy` |
| `/toggle/begin` | `authenticator_id`, `enabled`: JSON boolean | Changes the password-login setting only | `BEGIN`: `factor`, `desired_enabled`, `challenge` |
| `/toggle/confirm` | proof, identical `enabled` | Other settings are preserved | `UPDATED`: `updated: true`, `factor` |
| `/policy/begin` | `authenticator_id`, `policy`: string, `value` | Changes one supported policy value | `BEGIN`: `factor`, `policy`, `desired`, `challenge` |
| `/policy/confirm` | proof, identical `policy` and `value` | Other settings are preserved | `UPDATED`: `updated: true`, `factor`, complete `policy` |
| `/recovery/begin` | `authenticator_id` | Starts issuance or replacement of recovery codes | `BEGIN`: `factor`, `recovery_codes_remaining`, `challenge` |
| `/recovery/confirm` | proof | Replaces the entire previous code set | `GENERATED`: `generated: true`, `factor`, `recovery_codes`, `recovery_codes_remaining` |
| `/unlink/begin` | `authenticator_id` | Starts either TOTP or recovery-code unlink | `BEGIN`: `factor`, `challenge` |
| `/unlink/confirm` | proof | Full unlink using the authenticator | `UNLINKED`: `unlinked: true`, `factor` |
| `/unlink/recovery/confirm` | `authenticator_id`, `challenge_id`, `challenge_binding`, `recovery_code` | Uses the `/unlink/begin` challenge instead of TOTP | `UNLINKED`: `unlinked: true`, `factor` |
| `/unlink/federated/start` | `authenticator_id` | Requires an eligible linked identity | `BEGIN`: `redirect_url` |
| `/unlink/federated/confirm` | `authenticator_id`, `unlink_id`, `unlink_key` | Confirms the completed linked-account proof | `UNLINKED`: `unlinked: true`, `factor` |

## Read current state

```bash
curl --silent --show-error --request POST \
  'https://api.user.m7.org/api/v2/account/me/two_factor/status' \
  --header 'Authorization: Bearer ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{}'
```

Example for an account without a linked authenticator or eligible linked identity:

```json
{
  "status": 1,
  "comment": "OK",
  "data": {
    "factors": [],
    "policy": {
      "password_logins": false,
      "device_code": false,
      "account_switching": false,
      "account_switch_interval_seconds": 3600,
      "email_logins": false
    },
    "recovery": {"authenticator_id": null, "remaining": 0},
    "federated_unlink": {"available": false, "identity_count": 0}
  }
}
```

Each factor exposes only `id`, `type`, `name`, `status`, `enabled`,
`setup_expires`, `verified_at`, `created`, and `updated`. The type is `totp`;
status distinguishes `pending`, `linked`, and `revoked`. Historical rows may
remain in the list. Use the linked, verified row as the current authenticator.
Factor `enabled` is the password-login setting, not a master switch for all
four checks; use the boolean fields in `policy` for the complete state.

`recovery.remaining` is the unused-code count. The server does not return
existing recovery-code plaintext or the authenticator secret through status.
`federated_unlink.available` requires both a linked verified authenticator and
an eligible linked identity; `identity_count` is the eligible identity count.

## Link and verify an authenticator

1. Send `{"name":"My authenticator"}` to `/begin`.
2. Retain `data.factor.id`, `data.challenge.id`, and
   `data.challenge.binding` for this setup. `data.challenge.expires` and
   `factor.setup_expires` are UTC timestamps. Enrollment lasts ten minutes.
3. Add `data.totp_secret` to an authenticator app as a Base32 TOTP secret using
   SHA-1, six digits, and a 30-second period. M7's settings page uses issuer
   `M7` and renders its QR code locally. Do not send the setup secret to an
   external QR service.
4. Confirm with a code generated by the app before setup expires.

```bash
curl --silent --show-error --request POST \
  'https://api.user.m7.org/api/v2/account/me/two_factor/confirm' \
  --header 'Authorization: Bearer ACCESS_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{
    "authenticator_id": "AUTHENTICATOR_UUID",
    "challenge_id": "CHALLENGE_UUID",
    "challenge_binding": "CHALLENGE_BINDING",
    "totp_code": "CURRENT_SIX_DIGIT_CODE",
    "enable_immediately": true
  }'
```

Replace the placeholders with the returned identifiers and a current code;
preserve leading zeroes by sending `totp_code` as a string. Success returns
`status: 1`, `comment: "CONFIRMED"`, and
`data: {"confirmed": true, "factor": ...}`. The factor becomes `linked`.
`enable_immediately: true` enables password-login checks. Device Code,
account/session, and email checks are separate choices. Linking does not
require the account to have a password.

The setup secret is returned only by the enrollment response. If that response
is lost, start a new enrollment rather than expecting status to reveal it.
Once linked, unlink before enrolling a replacement authenticator.

## Change all sign-in settings together

Use `/policy_batch/begin` and `/policy_batch/confirm` for one editor and one
authenticator-code confirmation. Both requests must contain the same `policy`
object: the four required fields below and, when editing email checks, the
optional `email_logins` field. No other fields are accepted.

| Field | Type and allowed values | Effect |
| --- | --- | --- |
| `password_logins` | JSON boolean | Require a code after successful local password authentication in regular browser authorization. |
| `email_logins` | Optional JSON boolean; omission preserves its current value | Require an authenticator or recovery code after successful magic-link or email-code proof. |
| `device_code` | JSON boolean | Require a code during local-password Device Code approval. |
| `account_switching` | JSON boolean | Require a code when switching remembered consumer accounts or authorizing an application with an account already open in the browser. |
| `account_switch_interval_seconds` | Integer: `0`, `900`, `3600`, or `28800` | Every time, 15 minutes, one hour, or eight hours. Used by account/session checks. |

Absent saved choices default to `false`, with interval `3600`. Numeric strings,
string booleans, batches missing a required field, extra batch fields, and other
intervals are rejected. Older four-field clients may omit `email_logins` from
both requests; omission preserves the saved email choice. Explicit `false`
disables email checks, while `null`, `0`, `1`, and string values are rejected.
The settings are independent: switching password checks off does not switch
email, Device Code, or account/session checks off.

Begin request body:

```json
{
  "authenticator_id": "AUTHENTICATOR_UUID",
  "policy": {
    "password_logins": true,
    "device_code": true,
    "account_switching": true,
    "account_switch_interval_seconds": 900,
    "email_logins": true
  }
}
```

The response has `comment: "BEGIN"` and
`data: {"factor": ..., "desired": ..., "challenge": ...}`. Show those desired
values for confirmation. Send the same body to `/policy_batch/confirm`, adding
`challenge_id`, `challenge_binding`, and `totp_code`. Confirmation atomically
returns the saved `factor` and `policy`. If another change makes the starting
state stale, reload status and begin again; do not silently apply an old edit.
Canceling the editor leaves the settings unchanged.

For single-setting clients, `/toggle/*` changes only password checks using
`enabled`. `/policy/*` accepts the string `policy` values `device_code` or
`account_switching` with a boolean `value`, or
`account_switch_interval_seconds` with an allowed integer `value`.

See [SSO two-factor behavior](https://m7.org/docs/api/sso.user.m7.org/two-factor-authentication)
for freshness, federation, silent authorization, and refresh-token boundaries.

## Email sign-in policy

Read `data.policy.email_logins` from `/status`. It is a boolean and defaults
to `false` when no email choice is saved for the current linked authenticator.
Use `/policy_batch/begin` and `/policy_batch/confirm` to change it, including
the four required policy fields with the values you intend to retain. Send
the identical optional `email_logins` value at both stages. One current TOTP
proof authorizes the whole batch; a recovery code is not accepted in place of
that management `totp_code`.

`email_logins: true` requires a second factor after either magic-link or
email-code verification. `false` removes that email-specific check; it does
not disable email login itself, clear another policy, or unlink the
authenticator. The legacy `/policy/*` single-setting endpoints do not accept
`email_logins`; use the batch operation. Full unlink clears the email policy
alongside the other checks, so a replacement authenticator does not inherit it.

M7 hosts [email sign-in](https://m7.org/docs/api/sso.user.m7.org/email-sign-in)
within consumer authorization. Applications receive their usual authorization
callback and access token. Email codes and links are sign-in proofs, not
credentials for API User management. Fresh email proof does not by itself
authorize an authenticator settings change or unlink.

## Generate or replace recovery codes

Call `/recovery/begin` with the current `authenticator_id`, then
`/recovery/confirm` with its challenge and a current TOTP code. Success returns
ten new strings in `data.recovery_codes` and
`data.recovery_codes_remaining: 10`. Show or save them once in a secure place.
All previous codes become invalid as part of the successful replacement.
Status returns the remaining count, never the codes themselves.

Each code is single-use. Recovery codes can satisfy the hosted SSO recovery
option or `/unlink/recovery/confirm`. Generating a replacement set requires
the authenticator's current code. It does not accept a recovery code in place
of `totp_code`. If the generation response is lost after success, the old set
is still invalid; generate another set with a fresh TOTP proof.

## Unlink an authenticator

Full unlink disables all four checks, invalidates outstanding authenticator
challenges and all recovery codes, and permits a new enrollment. It does not
delete the M7 account or disconnect a linked identity provider.

For TOTP or recovery-code proof, call `/unlink/begin`, retain its challenge,
then choose exactly one confirmation route:

- `/unlink/confirm` with `totp_code`;
- `/unlink/recovery/confirm` with one unused `recovery_code`.

Both also require the original `authenticator_id`, `challenge_id`, and
`challenge_binding`. Success returns `comment: "UNLINKED"`,
`data.unlinked: true`, and a factor with `status: "revoked"` and `enabled: 0`.
Reload `/status` to display the resulting policy and recovery count.

### Unlink through a linked account

An active consumer account with an already verified, linked GitHub or Discord
identity may use that identity when its provider is available. This recovery
flow returns to the M7 settings page; it is not a configurable callback flow
for an arbitrary application.

1. Call `/unlink/federated/start` with the current `authenticator_id`.
2. Navigate the browser to the returned `data.redirect_url`. Treat the complete
   URL as sensitive and use it only for the M7-hosted proof flow.
3. The user selects an eligible identity and completes the provider flow. The
   identity must still match the linked account selected for this operation.
4. M7 returns to `https://user.m7.org/members/settings/2fa`. The settings page
   removes the temporary relay fields from the address bar and asks for an
   explicit final confirmation.
5. The confirmation submits the original `authenticator_id` and returned
   opaque `unlink_id` and `unlink_key` to `/unlink/federated/confirm` in JSON.

The proof expires within 15 minutes of starting and is single-use. A wrong,
changed, newly linked, expired, or replayed identity proof cannot unlink a
replacement authenticator. Returning from the provider alone does not unlink
anything. Cancellation leaves the authenticator intact. The provider may use
its own existing session; this flow does not guarantee a fresh password prompt
or provider-side MFA. Keep relay values out of logs, analytics, and persistent
browser storage.

## Challenges, errors, and retries

Setup challenges last ten minutes. TOTP management challenges last five minutes
and bind the account, authenticator, operation, and requested change. Send
`challenge.id` as `challenge_id` and `challenge.binding` as
`challenge_binding`. Each successful proof consumes its challenge. A TOTP
time step already used successfully cannot be reused; wait for a new code
before another successful operation.

Five failed proof attempts exhaust a challenge. Management proofs also share
a five-failure limit over a 15-minute window for the authenticator; starting
another challenge does not reset that limit. After expiry or exhaustion, wait
as necessary, refresh status, and begin a fresh operation.

Use the [standard response envelope](https://m7.org/docs/api/api.user.m7.org/README.md#request-and-response-format).
Check `status` as well as HTTP status. Normal workflow failures return
`status: 0`, safe `comment` text, and a `data.reason` such as:

| Reason | Next action |
| --- | --- |
| `enrollment_unavailable` | Check for an existing linked factor or invalid enrollment input. |
| `verification_failed` | Check the selected factor, proof, expiry, and current state; do not repeat indefinitely. |
| `toggle_unavailable`, `policy_unavailable`, `policy_batch_unavailable`, `recovery_unavailable`, `unlink_unavailable` | Reload status and check whether the operation remains available. |
| `active_user_required`, `linked_identity_required` | Linked-account unlink cannot start in the current account state. |
| `service_unavailable` | Preserve current state and retry later; a runtime failure is not proof of success. |

Malformed requests, rejected principal fields, and authorization failures may
use the ordinary API failure envelope before the workflow starts. Do not depend
on human-readable comments for control flow. After an ambiguous network result,
read status before attempting another mutation; a lost response does not undo
a committed settings change or unlink.

These responses use no-store cache headers. Never log setup secrets, TOTP
codes, recovery codes, challenge bindings, relay values, or access tokens.
