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
- Send
{"name":"My authenticator"}to/begin. - Retain
data.factor.id,data.challenge.id, anddata.challenge.bindingfor this setup.data.challenge.expiresandfactor.setup_expiresare UTC timestamps. Enrollment lasts ten minutes. - Add
data.totp_secretto an authenticator app as a Base32 TOTP secret using SHA-1, six digits, and a 30-second period. M7's settings page uses issuerM7and renders its QR code locally. Do not send the setup secret to an external QR service. - 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/confirmwithtotp_code;/unlink/recovery/confirmwith one unusedrecovery_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.
- Call
/unlink/federated/startwith the currentauthenticator_id. - 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. - The user selects an eligible identity and completes the provider flow. The identity must still match the linked account selected for this operation.
- 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. - The confirmation submits the original
authenticator_idand returned opaqueunlink_idandunlink_keyto/unlink/federated/confirmin 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.