Two-factor authentication

Manage the signed-in consumer account's authenticator, sign-in settings, and recovery codes. The M7 two-factor settings page 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. 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

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:

{
  "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.
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:

{
  "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 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 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. 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.