# MFA and passkeys: enrollment, proof, replacement, and recovery

Follow each authenticator from account binding to removal, and validate WebAuthn assertions without mistaking client UI or counter behavior for a security guarantee.

## Investigation sequence

1. Record assurance policy
2. Enroll controlled factors
3. Compare fresh and stale proof
4. Check assertion bindings
5. Replace and recover
6. Verify removal

## Used in these playbooks

[Web & API](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/web-api.html) · [OSS & self-hosted](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/oss.html) · [Mobile](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/mobile.html) · [Desktop & extensions](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/desktop.html) · [Cloud & containers](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/cloud.html)

### Setup and invariant

Reuse [identity/session fixtures](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/modules/identity-session.html). Prepare two disposable accounts, recovery access, separate browser profiles, and one reversible protected operation. Record whether the product promises MFA, passwordless login, recent reauthentication, or a particular authenticator class; these are different contracts. The invariant is: **a proof must authorize the intended account, ceremony, and assurance level, including factor management**. Capture the server's stored factor list and audit events, not just a green browser badge.

### Numbered procedure

1. **Test enrollment before authentication.** Register A's first and second factors through the legitimate UI. Compare an authenticated registration with anonymous, MFA-pending, and stale-session attempts to complete that same observed operation. For an account-bound enrollment flow, begin in A and attempt completion in B, changing only the account/session context. Confirm the final factor owner with a fresh login. For username-less authentication, successful login to the factor's rightful owner is expected; it must not be reported as cross-account bypass.
2. **Exercise replacement and removal.** Capture normal add, rename, delete, recovery-code regeneration, and fallback changes. In the replica, replay one sensitive factor change without the required recent proof, then with a fresh valid proof. Verify whether a newly added factor can log in and whether the removed factor still authenticates. Preserve a recovery route throughout. A factor addition that only changes a label proves nothing. Factor replacement deserves the same care as initial login. [OWASP MFA management](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html).
3. **Test OTP and recovery consumption separately.** Use A's successful OTP once more in a fresh pending login during its validity window; use an invalid code as the negative control. Test one recovery code, then attempt the consumed code again. Regenerate the recovery-code set and check one old code. Do not brute-force codes or trigger real push/SMS floods. Record which transaction succeeded, not merely whether the numeric value appeared twice. TOTP verification must prevent reuse after successful validation; an identical code displayed during one time step is normal. [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238.html). Saved recovery credentials also need explicit lifecycle controls. [NIST authenticator management](https://pages.nist.gov/800-63-4/sp800-63b.html).
4. **Build a real passkey baseline.** In the local application, open DevTools → More tools → WebAuthn, enable the virtual authenticator, select CTAP2 and the required resident-key/user-verification capabilities, then register through the application's normal page. Record the RP ID and credential owner. This tests server integration; it does not demonstrate compromise of a hardware authenticator. [Chrome virtual-authenticator workflow](https://developer.chrome.com/docs/devtools/webauthn/).
5. **Separate signature checks from semantic checks.** Compare a fresh assertion with a consumed assertion and one with an invalid signature. For username-first login, verify the credential belongs to the intended account; for discoverable credentials, verify the returned user handle resolves consistently to the credential owner. Trace the verifier's challenge, ceremony type, origin, RP-ID hash, signature, and presence/verification decisions. A local authenticator fixture can sign a deliberately mismatched case; changing signed JSON without re-signing only tests signature rejection. [WebAuthn assertion fields](https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/WebAuthn_Client_Authentication.html).
6. **Check policy-sensitive WebAuthn branches.** Verify UV when the ceremony requires user verification; a touch proving user presence does not by itself prove UV. Account for an explicitly configured related-origin deployment and applicable legacy AppID handling before calling an origin/RP mismatch a bypass. Counter anomalies are risk signals, not standalone clone proof; some authenticators keep zero counters. Synced credentials and absent attestation are not automatically weaknesses. Evaluate the deployed library and RP policy against the pinned [WebAuthn Level 3 Recommendation](https://www.w3.org/TR/2026/REC-webauthn-3-20260825/).

### Local inspection card

Export an **owned test assertion** in the application's JSON transport format to `assertion.json`. This prints selected metadata and a challenge fingerprint; it does not verify the signature or establish a vulnerability. If your wrapper uses different keys, adapt the field path.

```bash
python3 - <<'PYCODE'
import base64, hashlib, json
from pathlib import Path
x = json.loads(Path('assertion.json').read_text())
s = x['response']['clientDataJSON']
c = json.loads(base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)))
print(json.dumps({
    'type': c.get('type'), 'origin': c.get('origin'),
    'crossOrigin': c.get('crossOrigin'),
    'challenge_sha256': hashlib.sha256(c['challenge'].encode()).hexdigest()
}, indent=2))
PYCODE
```

### Evidence and completion

Report a newly usable unauthorized factor, authentication below a required assurance level, or acceptance of a proof for the wrong account/transaction. Attach the expected rule, before/after factors, request order, independent fresh-login result, and cleanup. Missing notification alone is hardening evidence; a successful server rejection is a passed check.

- [ ] Enrollment and replacement evaluated independently.
- [ ] OTP, backup codes, and passkey ceremonies have separate controls.
- [ ] Username-first and discoverable flows distinguished.
- [ ] Signature rejection separated from signed-context validation.
- [ ] Removed test factors and credentials no longer authenticate.
- [ ] Virtual authenticator exports and live recovery secrets excluded from reports.


## Vulnerability reverse references

- [Authentication or MFA state bypass](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/vulnerabilities/authentication-incomplete-state-bypass.html) — A session that has not completed required authentication can perform protected operations.
- [Unauthorized authenticator enrollment, replacement, or removal](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/vulnerabilities/authenticator-management-proof-bypass.html) — A lower-assurance or wrong-account context can add, replace, or remove an authentication factor contrary to its management policy.
- [WebAuthn assertion accepted for an invalid account or ceremony context](https://bug-bounty-field-manual.kmahshooq.chatgpt.site/vulnerabilities/webauthn-assertion-binding-failure.html) — The relying party accepts a passkey assertion without the required account, challenge, signature, origin, or verification binding.
