switching clears the token & responses
No token
27

Disabling MFA and regenerating recovery codes

Multi-factor authentication

Two security-sensitive self-service operations that follow once MFA is on. Both need a STRONG step-up token (§153 X-13): the password alone is never enough — prove a factor (a code to a verified channel, a TOTP, or a recovery code). Mint a separate token for each action: a token is bound to ONE action.

  1. GET /v1/me/mfa/methods → check which factors are enrolled first
  2. POST /v1/me/step-up { action: mfa_disable | mfa_recovery_codes, currentPassword } → a password-assurance token and the factors on offer (passwordOnlyAllowed: false)
  3. POST /v1/me/step-up/{challengeId}/code → only for a code factor (email_code / phone_code); skip it for a TOTP or a recovery code
  4. POST /v1/me/step-up/{challengeId}/verify → a STRONG token for the same action
  5. DELETE /v1/me/mfa { stepUpToken } → every method off; hasMfaEnabled → false
  6. POST /v1/me/mfa/recovery-codes { stepUpToken } → all old codes invalid; 8 new ones, shown ONCE (needs its own mfa_recovery_codes token)
GET/v1/me/mfa/methodsauthList enrolled MFA methods

Shows the enabled methods (phone_otp / totp) and whether MFA is on — review before disabling. The confirmed methods are also what the step-up below can offer as factors (totp, and a code to the phone behind phone_otp).

ℹ︎Field guide — what each value means & where it comes fromno inputs

Bodyless GET — no input fields. Requires a valid bearer access token (the gateway injects x-gateway-context from it); a missing/invalid context yields 403 forbidden.

Bearer token
→ GET https://api.kerja.team/v1/me/mfa/methods
↩︎Response guide — what comes back & what each value means4 fields

200 OK, JSON { data } envelope (not raw). No notable headers (no no-store on this read endpoint).

data.hasMfaEnabledbooleanalways
True when the caller has at least one confirmed MFA method (phone_otp and/or totp). The client uses it to show whether MFA is active overall and to gate enrol/disable affordances.
  • true — Caller has >=1 confirmed MFA method; show MFA-enabled state and offer disable/add-method actions.
  • false — No confirmed MFA method; prompt the user to enrol a second factor.
data.methodsarray<object>always
List of the caller's enrolled (confirmed) MFA methods. Empty array when none are enrolled. The client iterates it to render active factors and offer the correct enrol/disable actions per method.
data.methods[].methodstringalways
The kind of second factor for this entry. Identifies which enrol/disable flow applies to the row.
  • phone_otp — SMS one-time-passcode method is enrolled; offer phone-OTP disable/manage.
  • totp — Authenticator-app TOTP method is enrolled; offer TOTP disable/manage.
data.methods[].confirmedAtstring (ISO 8601 date-time)always
Timestamp when this method was confirmed/activated. The client can display when the factor was enabled.
POST/v1/me/step-upauthStart a step-up for mfa_disable

Proves the CURRENT password and answers { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? } — both ids are captured and filled into the cards below. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice is 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices; when false, prove one of options[] (a code to a verified, non-young CURRENT value — never a pending one — a TOTP, or a recovery code) for a strong token. Errors: wrong password 401 step_up_failed (counted per user AND per session — 5 in 15 min → 429, and a second throttle trip within 24 h revokes the session); 403 without an acting session (operator / act-as contexts have none); 409 no_password (GET /v1/me → hasPassword: false — set one through the reset flow, scenario 34); 409 change_in_progress when a live change blocks this action (details[0].blocking). Change action to mfa_recovery_codes (and press Send again) before regenerating codes — the token that comes back is bound to ONE action, and spending an mfa_disable token on recovery codes is 401 step_up_required. For both actions passwordOnlyAllowed is false: prove a factor below.

⛓ Needs an out-of-band value?
🔐 Step-upSend to open a challenge — its challengeId and token are captured for the cards below
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Starts a step-up (E-1) for ONE named profile action: proves the CURRENT password and answers 200 { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? }. Every profile mutation — display name, login mode, email / phone / password change, phone removal, MFA disable, recovery-code regeneration — spends one of these tokens; a live session alone is never enough. Auth + throttled + no-store; the body is .strict(). THE TESTER AUTO-CAPTURES BOTH IDS: challengeId is filled into the …/code and …/verify cards on this page, and stepUpToken into the card that spends it. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice → 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices for the action; when false, continue with …/code (for a code factor) and …/verify for a strong token. options[] are the factors …/verify will accept: a code factor always names the CURRENT value on the account — never a pending one — and only when it is verified and not young (applied with password assurance inside CHANNEL_TRUST_AGE_SECONDS, default 30 days); totp appears when a confirmed authenticator exists, recovery_code while unused codes remain. The challenge lives STEP_UP_CHALLENGE_TTL_SECONDS (default 600) and takes STEP_UP_MAX_ATTEMPTS (default 5) wrong codes. Errors: 401 step_up_failed (wrong password — counted per user AND per session, 5 in 15 min → 429, a second throttle trip within 24 h revokes the session) · 403 without an acting session (operator / act-as contexts have none) · 409 no_password (GET /v1/me → hasPassword: false; set one through the reset flow, scenario 34) · 409 change_in_progress when a live change blocks the action (details[0].blocking lists { kind, status }) · 409 login_channel_unavailable (password_change with no verified loginMode channel). Audited step_up_succeeded / step_up_failed.

actionbodyenumrequiredyou choose
The ONE profile action this step-up is for. You pick it to match the card that will spend the token — a token minted for one action is refused by every other (401 step_up_required). The concurrency rules run HERE too, so a blocked action is refused before a token is minted.
  • display_name — For PUT /v1/me/display-name (scenario 28). passwordOnlyAllowed: true — the password token is enough. Never blocked by a live change.
  • email_change — For POST /v1/me/email-change (scenario 30). passwordOnlyAllowed: true on an account WITHOUT confirmed TOTP; false with one, and for a second contact change beside a strong one. A PASSWORD-assurance change is HELD 3 days after its code and sends notices with a cancel link; a STRONG one applies at once. Blocked (409 change_in_progress) by a live email or password change, and beside a live phone change unless both are strong.
  • phone_change — For POST /v1/me/phone-change (scenario 31, and the link-mode add of scenario 4). Same rules as email_change: password enough without confirmed TOTP; held vs applied by assurance; one live phone change at a time, never beside a password change, beside an email change only when both are strong.
  • password_change — For POST /v1/me/password-change (scenario 35). passwordOnlyAllowed: true. The answer carries loginChannel — the CURRENT loginMode channel the confirmation code will go to, which must be verified (else 409 login_channel_unavailable). Blocked by, and blocks, any live contact change.
  • login_mode — For PUT /v1/me/login-mode (scenario 33). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.
  • phone_remove — For DELETE /v1/me/phone (no §2 scenario walks this any more — the doc dropped “Removing a phone credential” at its 2026-09-27 revision, while §3.31 and this action stay live). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.
  • mfa_disable — For DELETE /v1/me/mfa (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.
  • mfa_recovery_codes — For POST /v1/me/mfa/recovery-codes (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.
currentPasswordbodystringrequiredyou choose
Your CURRENT account password, 1–1024 chars — the one place in the §153 flows the current password travels; no change body ever carries it again. Wrong → 401 step_up_failed (throttled per user and per session, see above). A phone-only account with no password gets 409 no_password — set one via the reset flow (scenario 34) first. If you do not know the seeded password, the “Set a known password” helper sets one.
Bearer token
→ POST https://api.kerja.team/v1/me/step-up
↩︎Response guide — what comes back & what each value means12 fields

200 OK, { data } envelope, Cache-Control: no-store. The current password was proven for ONE named action, and this is the PASSWORD-assurance step-up token plus the factors that can raise it to strong. Every profile mutation (display name, email / phone / password change, login mode, phone removal, MFA disable, recovery codes) spends one of these tokens — a live session alone is never enough. The tester captures data.challengeId and data.stepUpToken and fills them into the next cards. 401 step_up_failed = wrong password (5 in 15 min → 429; a second throttle trip in 24 h revokes the session); 409 no_password / change_in_progress / login_channel_unavailable refuse BEFORE a token is minted.

data.challengeIdUUIDalways
The step-up challenge. Path parameter of POST /v1/me/step-up/{challengeId}/code and /verify. Lives STEP_UP_CHALLENGE_TTL_SECONDS (600) and takes STEP_UP_MAX_ATTEMPTS (5) wrong codes.
data.stepUpTokenstring (43-char opaque)always
Single-use token bound to this user, this SESSION, this action and password assurance. Spend it on the action's route when passwordOnlyAllowed is true; spending it on another action, from another session or twice → 401 step_up_required. It stays valid alongside the strong token /verify later mints.
data.assuranceenumalways
What the token proves.
  • password — Always this value here — only the current password has been proven. A strong token comes only from POST /v1/me/step-up/{challengeId}/verify.
data.expiresInnumberalways
Token lifetime in seconds (STEP_UP_TOKEN_TTL_SECONDS, default 300).
data.passwordOnlyAllowedbooleanalways
Whether this token, as minted, already suffices for the action.
  • true — Spend stepUpToken now: display_name and password_change always; a contact change on an account without confirmed TOTP (that change is then HELD after its code). Raising to strong is still possible and makes a contact change apply at once.
  • false — Password is not enough: login_mode, phone_remove, mfa_disable, mfa_recovery_codes, and a second contact change beside a strong one. Continue with /code (for a code factor) and /verify to get a strong token.
data.optionsarrayalways
The factors /verify will accept for this challenge. A code factor always goes to the CURRENT value on the account (never a pending one) and only if that value is verified and not young (applied on password assurance inside CHANNEL_TRUST_AGE_SECONDS, 30 days). For a contact change the channel being changed is one option among the others, never the only one.
data.options[].factorenumalways
The factor kind — pass it as factor to /code and /verify.
  • email_code — A code to the verified current email: send it with /code, then /verify.
  • phone_code — A WhatsApp/SMS code to the verified current phone: send it with /code, then /verify.
  • totp — Offered when a confirmed authenticator exists. Nothing to send — go straight to /verify with the app's code.
  • recovery_code — Offered while unused recovery codes remain. Nothing to send — /verify with one; it is consumed.
data.options[].roleenumconditional
Code factors only: how the channel relates to the action. The list holds whatever is ELIGIBLE and nothing more — an account whose only eligible factor is the channel being changed is offered that alone, and one with no eligible factor at all is offered an EMPTY list and must proceed password-only (accepting the three-day hold on a contact change).
  • current_value — The channel whose value this very action changes (e.g. the current email in an email change) — proving it confirms the owner of the old value.
  • other_channel — The account's OTHER verified channel — proving it shows control of a second, independent credential.
  • channel — The role used for the actions that change no channel value of their own — login_mode, phone_remove and the two MFA actions: every eligible channel is simply offered as a channel. (For phone_remove the phone is still offered as current_value, because that IS the value being removed.)
data.options[].maskedstringconditional
Code factors only: the masked destination (b•••@old.id, +62 812••••21) so the user can choose. Absent for totp / recovery_code.
data.loginChannelobjectconditional
password_change only: where the confirmation code of POST /v1/me/password-change will go — the current value of the loginMode channel, which must be verified (else 409 login_channel_unavailable).
data.loginChannel.channelenumconditional
The sign-in channel.
  • email — The password-change code will be emailed.
  • phone — The password-change code will go to the phone by WhatsApp/SMS.
data.loginChannel.maskedstringconditional
The masked destination — show it before the user submits the new password.
POST/v1/me/step-up/{challengeId}/codeauthSend a step-up code to one of the offered channels

Only for a CODE factor: factor is email_code or phone_code and must be one of the challenge's options[] (else 422 factor_not_eligible). 202 { status, masked, ttlSeconds }. The code is BOUND to this challenge, and a second send retires the first. Skip this card for a TOTP or a recovery code — they go straight to verify. 404 for an unknown or expired challenge (STEP_UP_CHALLENGE_TTL_SECONDS, default 600); 429 on the per-user, per-channel send throttle.

🔐 Step-upno challenge yet — start the step-up on the card above
Path parameters
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Sends a step-up code (E-2) to ONE of the challenge's channel options — the step_up.<action> message, in the caller's locale. 202 { status: accepted, masked, ttlSeconds }. The code is bound to THIS challenge, and a second send retires the first. Each code sent takes STEP_UP_MAX_ATTEMPTS (default 5) wrong guesses — email and phone alike — after which that CODE is burnt and a resend is needed; the challenge itself is separately capped at 5 failed proofs of any factor code. Auth + throttled (per user, per channel → 429) + no-store; the body is .strict(). Only needed for a code factor — a totp or recovery_code factor goes straight to …/verify. 404 when there is no such (live) challenge for the caller; 422 factor_not_eligible when the factor is not among the challenge's options.

challengeIdpathUUIDrequiredauto-captured
The challenge POST /v1/me/step-up just opened — auto-filled from the step-up card on this page (a fresh start supersedes it). Expired, unknown, or another user's → 404 not_found.
factorbodyenumrequiredfrom a response
Which channel to send the code to. Must be one of the challenge's options[] from the step-up answer (else 422 factor_not_eligible) — a code always goes to the CURRENT, verified, not-young value, never to a pending one. For a contact change the channel being changed is one option among the others, never the only one.
  • email_code — Emails a code to the account's current verified address (~15 min). Retrieve it with the “Get a step-up code” helper (by challengeId).
  • phone_code — Sends a WhatsApp/SMS code to the account's current verified number (~5 min). Retrieve it with the “Get a step-up code” helper (by challengeId).
Bearer token
→ POST https://api.kerja.team/v1/me/step-up/{challengeId}/code
↩︎Response guide — what comes back & what each value means3 fields

202 Accepted, { data } envelope, Cache-Control: no-store. A step-up code (the step_up.<action> message, in the caller's locale) went to the chosen channel option. The code is bound to THIS challenge; a second send retires the first code. 404 = no such challenge for this caller (or it expired); 422 factor_not_eligible = the factor is not among the challenge's options; 429 = per-user, per-channel throttle.

data.statusenumalways
Send acknowledgement.
  • accepted — The only value: the code is on its way. Collect it (dev: the step-up OOB helper) and call /verify with the same factor.
data.maskedstringalways
The masked destination the code went to — tell the user which inbox / handset to check.
data.ttlSecondsnumberalways
Seconds the code lives (e.g. 300). Past it, send again.
POST/v1/me/step-up/{challengeId}/verifyauthProve the factor → a STRONG token

factor is email_code · phone_code · totp · recovery_code (one of the challenge's options), code is the value that proves it. 200 { stepUpToken, assurance: strong, expiresIn } — captured, and it REPLACES the password token in the card that spends it. The password token stays valid alongside it. A used recovery code is consumed. Wrong, expired or exhausted code → 401 step_up_failed; the challenge takes STEP_UP_MAX_ATTEMPTS (default 5) wrong codes and closes once it succeeds or is exhausted.

⛓ Needs an out-of-band value?
🔐 Step-upno challenge yet — start the step-up on the card above
Path parameters
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from3 fields

Proves a factor (E-3) — the code from …/code, a TOTP, or a recovery code — and answers 200 { stepUpToken, assurance: strong, expiresIn } for the SAME action. THE TESTER CAPTURES IT and it REPLACES the password token in the card that spends it; the password token from the start stays valid alongside it. The challenge closes once it succeeds or its attempts are exhausted (STEP_UP_MAX_ATTEMPTS, default 5). Auth + no-store; .strict(). Errors: 401 step_up_failed (wrong, expired or exhausted code; a TOTP or recovery code that did not verify; and the CHALLENGE as a whole takes STEP_UP_MAX_ATTEMPTS (default 5) failed proofs of ANY factor, after which it is DELETED and every later call answers 404) · 404 no such challenge · 422 factor_not_eligible. A used recovery code is consumed. Audited step_up_succeeded with stepUpMethod = the factor.

challengeIdpathUUIDrequiredauto-captured
The challenge from POST /v1/me/step-up — auto-filled from the step-up card on this page. Must be the same challenge the code was sent for (…/code binds a code to ONE challenge).
factorbodyenumrequiredfrom a response
Which factor code carries. Must be one of the challenge's options[] (else 422 factor_not_eligible).
  • email_code — code is the email code sent by …/code with factor email_code. Send …/code first — the “Get a step-up code” helper REPLACES the hash of the code row that send created, so there is nothing to fetch before it.
  • phone_code — code is the WhatsApp/SMS code sent by …/code with factor phone_code. Same helper, same order: send, then “Get a step-up code”.
  • totp — code is the current 6-digit TOTP from the enrolled authenticator — use the “Get current TOTP code” helper. Offered only when a CONFIRMED authenticator exists. No …/code send.
  • recovery_code — code is one of your unused MFA recovery codes (from enrolment or the last regeneration). Offered only while unused codes remain; CONSUMED on success. No …/code send.
codebodystringrequiredout-of-band
The value that proves the chosen factor, trimmed, 1–32 chars: the email / phone code delivered out-of-band (locally via the “Get a step-up code” helper, by challengeId), a live TOTP (“Get current TOTP code”), or a recovery code you saved. Wrong, expired or exhausted → 401 step_up_failed, and each wrong code spends one of the challenge's attempts.
Bearer token
→ POST https://api.kerja.team/v1/me/step-up/{challengeId}/verify
↩︎Response guide — what comes back & what each value means3 fields

200 OK, { data } envelope, Cache-Control: no-store. A second factor was proven (the /code code, a TOTP, or a recovery code — the latter consumed) and this is a STRONG step-up token for the same action. The challenge is closed once it succeeds or its attempts run out. The tester captures data.stepUpToken, which replaces the password-assurance token in the next cards (that earlier token stays valid alongside it). 401 step_up_failed = wrong / expired / exhausted code. Audits step_up_succeeded with stepUpMethod = the factor.

data.stepUpTokenstring (43-char opaque)always
The strong token — single-use, bound to this user, session and action. Spend it on the action's route: required for PUT /v1/me/login-mode, DELETE /v1/me/phone, DELETE /v1/me/mfa and POST /v1/me/mfa/recovery-codes; on an email / phone change it makes the change APPLY at its code rather than be held.
data.assuranceenumalways
What the token proves.
  • strong — Always this value here: password plus a second factor. Satisfies every step-up-gated action it was minted for.
data.expiresInnumberalways
Token lifetime in seconds (STEP_UP_TOKEN_TTL_SECONDS, default 300).
DELETE/v1/me/mfaauthDisable MFA (strong mfa_disable token)

The body is { stepUpToken } only (.strict()); it must be a STRONG token minted for mfa_disable — no, spent, foreign, wrong-action or password-assurance → 401 step_up_required. 200 {}. Turns off ALL methods. Does NOT revoke existing sessions and does NOT untrust devices: sessions created with amr [pwd, mfa] keep running, only future logins skip the MFA hop. TENANT ENFORCEMENT REFUSES THE CALL OUTRIGHT: if ANY tenant you hold an active membership in requires MFA, this answers 403 mfa_enrollment_required and nothing is disabled — enforcement is at the point of REMOVAL, not only at session issue and switch-tenant. Letting the disable succeed and rejecting you at the next login would make the policy unenforceable in the one direction that matters, and the person who paid for it would be you, locked out by an action the API had just called fine. Any one confirmed method satisfies enforcement, so a member holding two may still remove one. An account without a password cannot start the step-up at all (409 no_password).

🔐 Step-upno token yet — start the step-up above
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from1 field

Disables MFA — every enrolled method — on a STRONG step-up token minted for mfa_disable (§153 X-13). Auth (the tester sends the bearer; the gateway injects x-gateway-context). The body is .strict() and carries ONLY the token: the current password and the old otpCode factor are gone from this body, and a live session plus the password alone is no longer enough — the caller proves a SECOND factor at POST /v1/me/step-up/{challengeId}/verify (a code to a verified channel, a TOTP, or a recovery code). 200 { data: {} }. Tenant enforcement still applies after the token: if any tenant you hold an ACTIVE membership in requires MFA, the call is refused 403 mfa_enrollment_required and nothing is disabled — a member cannot opt out of a policy their admin set (scenario 55). Existing sessions are NOT revoked. Audited mfa_disabled. Walk-through: scenario 27.

stepUpTokenbodystringrequiredauto-captured
A STRONG token minted for action mfa_disable. Filled in automatically from the step-up cards on this page: POST /v1/me/step-up { action: mfa_disable, currentPassword } mints a PASSWORD-assurance token (passwordOnlyAllowed: false for this action), then …/code + …/verify prove a factor and REPLACE it with the strong one — so send this only after the verify card answered assurance: strong. A missing, spent, foreign-session, wrong-action or password-assurance token is the same 401 step_up_required. Single-use; lives STEP_UP_TOKEN_TTL_SECONDS (default 300).
Bearer token
→ DELETE https://api.kerja.team/v1/me/mfa
↩︎Response guide — what comes back & what each value means1 field

200 OK, { data } envelope with Cache-Control: no-store; data is an empty object. Reached only with a STRONG step-up token minted for mfa_disable (scenario 27) — the password alone is not enough: a second factor (a code to a verified channel, a TOTP, or a recovery code) was proven at POST /v1/me/step-up/{challengeId}/verify. The token is spent. All MFA methods are cleared and hasMfaEnabled becomes false; existing sessions are NOT revoked. Audits mfa_disabled. A 401 step_up_required means the token was missing, spent, foreign, minted for another action or only password-assurance; 403 mfa_enrollment_required means a tenant you belong to enforces MFA.

dataobject (empty)always
Empty object {} — MFA is disabled. Re-read GET /v1/me (hasMfaEnabled false) or GET /v1/me/mfa/methods to reflect it.
POST/v1/me/mfa/recovery-codesauthRegenerate recovery codes (strong mfa_recovery_codes token)

A STRONG token minted for mfa_recovery_codes — re-run the step-up above with that action first. Invalidates all existing codes and returns 8 new ones, shown ONCE — save them immediately, they are not retrievable after the response. MFA must already be enabled → 409 conflict otherwise, and that precondition is checked BEFORE the token is spent, so a doomed request costs nothing.

🔐 Step-upno token yet — start the step-up above
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from1 field

Regenerates MFA recovery codes (invalidate-and-reissue) on a STRONG step-up token minted for mfa_recovery_codes (§153 X-13). Auth (bearer + gateway context, handled). The body is .strict() and carries ONLY the token — the old currentPassword / otpCode factors are gone. MFA must already be enabled: that precondition is checked BEFORE the token is spent, so a doomed request (409 conflict, nothing to regenerate) costs nothing. 200 { recoveryCodes: [8 codes] } — the previous codes are dead and the new ones are shown ONCE. Audited recovery_codes_regenerated. Walk-through: scenario 27.

stepUpTokenbodystringrequiredauto-captured
A STRONG token minted for action mfa_recovery_codes — a token minted for mfa_disable will not do (a token is bound to ONE action). Filled in automatically from the step-up cards on this page once the …/verify card has upgraded it to assurance: strong; the password-only token from the start card is refused. Missing, spent, foreign-session, wrong-action or password-assurance → 401 step_up_required. A recovery code used AS the step-up factor is consumed before these new ones are issued.
Bearer token
→ POST https://api.kerja.team/v1/me/mfa/recovery-codes
↩︎Response guide — what comes back & what each value means1 field

200 OK with a { data } envelope and Cache-Control: no-store. Reached only with a STRONG step-up token minted for mfa_recovery_codes (scenario 27); MFA must already be enabled, checked BEFORE the token is spent (409 conflict otherwise, token intact). Previous recovery codes are immediately invalidated and 8 fresh codes are returned exactly once. Audits recovery_codes_regenerated.

data.recoveryCodesstring[]always
8 newly generated single-use recovery codes, shown only this once — surface them for the user to save. The previously issued codes are now dead. A recovery code is also a valid factor at POST /v1/me/step-up/{challengeId}/verify, where it is consumed.