---
key: "passkeys-mfa-lifecycle"
title: "Passkeys and MFA: WebAuthn enrollment, sign-in and replacement"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/passkeys-mfa-lifecycle/"
api: "https://softwaresecurity.com/api/v1/articles/passkeys-mfa-lifecycle.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-308","CWE-294","CWE-1390","A07:2025","API2:2023","V6.3.3","V6.4.3","V6.5.1","V6.6.1","V6.6.4","V6.7.2"]
tags: ["MFA and passkeys"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Passkeys and MFA: WebAuthn enrollment, sign-in and replacement

Register, verify and replace passkeys with WebAuthn so phishing resistance survives the account’s whole lifecycle, and map passkeys and other MFA methods to the NIST SP 800-63B-4 assurance levels.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/passkeys-mfa-lifecycle/

## In short

Register passkeys with WebAuthn from a recently reauthenticated session, require user verification, and verify every response with a maintained library: one-time challenge, origin, RP ID hash, signature and the user-presence and user-verification flags. Store the BE and BS flags, offer passkeys as the phishing-resistant option that NIST SP 800-63B-4 requires at AAL2, and require device-bound keys for AAL3, where synced passkeys are not allowed.

- A passkey assertion is bound to your origin, so a look-alike phishing site cannot obtain one that verifies.
- Challenges are single-use and expire; a correctly signed but replayed assertion must fail.
- Require user verification (`userVerification: "required"`) when the passkey is the whole login.
- Adding a passkey grants durable authority: require recent reauthentication or approved recovery first.
- Synced passkeys (BE flag set) are allowed at AAL2 but not AAL3; device-bound keys can reach AAL3.
- Approve-only push prompts and SMS codes are not phishing-resistant; NIST restricts SMS and voice codes.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| User verification | `userVerification: "required"` and checked on the server | Without the UV flag the passkey counts as a single factor. | NIST SP 800-63B-4 appendix B; WebAuthn Level 3 |
| Discoverable credential | `residentKey: "required"` for passkey sign-in | Lets the user sign in without typing a username first. | WebAuthn Level 3 section 5.4.6 |
| Attestation | `"none"` for consumer sign-in | Use enterprise or direct attestation only where you validate it against a policy. | NIST SP 800-63B-4 appendix B |
| Challenge | At least 64 random bits, single use, short expiry | Library generators produce 256 bits; consume the challenge even when verification fails. | NIST SP 800-63B-4 section 3.1.7; ASVS 5.0 V6.7.2 |
| Backup flags | Store BE and BS with each credential and update BS at each sign-in | BE cannot change after registration; BS can. | WebAuthn Level 3 section 6.1.3 |
| Assurance | Synced passkeys: up to AAL2; AAL3 needs a non-exportable key | AAL2 verifiers must offer at least one phishing-resistant option. | NIST SP 800-63B-4 sections 2.2.2 and 2.3.2 |
| Out-of-band and push codes | Valid for 10 minutes or less, single use; rate-limit push prompts | Approve-only and pick-from-a-list prompts are no longer acceptable to NIST. | NIST SP 800-63B-4 section 3.1.3; ASVS 5.0 V6.6.4 |

## Verify it

1. Register a passkey with the Chrome DevTools virtual authenticator and confirm the stored record has the account ID, public key, counter, BE and BS.
2. Replay a captured sign-in response: it must fail because its challenge was consumed.
3. Sign in with user verification turned off in the virtual authenticator: the server must reject it.
4. From a session older than the reauthentication window, try to add a passkey: the server must require authentication first.
5. Exceed the push or SMS attempt limit and confirm further prompts stop until the documented reset.

## What you will learn

- Explain what the signed WebAuthn assertion proves and what it does not.
- Protect enrollment as a privilege change.
- Map passkeys, TOTP, push and SMS to the NIST assurance levels.
- Test replay and recovery without assuming every passkey has the same storage model.

Audience: Developers adding passkeys or other MFA to an account system, and reviewers who check enrollment, assurance levels and recovery.

Before you start:

- Distinguish account identity, credential enrollment, user verification and fallback recovery.

Time: About 30 minutes: 7 to read and 23 for the exercise

## A passkey proves possession in a relying-party context

WebAuthn uses public-key credentials scoped to a relying party. During authentication, the server supplies a fresh challenge; the authenticator signs data that includes authenticator data and a hash of client data. The relying party verifies the stored public key, expected challenge, origin, relying-party identifier hash, and required user-presence or user-verification flags.

The server stores a credential identifier, public key, account binding, and relevant metadata rather than a reusable shared password. A phishing page at an unrelated origin cannot simply ask the browser for a valid assertion for the legitimate relying party. That origin binding is a central property, but it does not stop an already compromised legitimate application from acting within its own origin.

User presence and user verification are different signals. Touching an authenticator can establish presence (the UP flag); local biometric or PIN verification establishes the authenticator’s user-verification result (the UV flag). The server must enforce the result required by its policy rather than assuming that every WebAuthn interaction used a biometric. Native apps use the platform APIs, such as Android Credential Manager and Apple’s `AuthenticationServices`, and must be associated with the RP ID through Digital Asset Links or associated domains; the server verification is the same.

## Registration gives a new device future authority

A fresh credential must be attached to the intended authenticated account through a recent, appropriate proof of control. If an attacker steals an ordinary session and can add a passkey without further checks, the attacker can convert temporary access into a durable new authenticator. The account recovery guide at /domains/identity-crypto/authentication/account-recovery-linking/ covers the other route to a new credential.

Create a single-use, expiring registration challenge bound to the account and session. Validate the response through a maintained WebAuthn implementation, including challenge, origin, RP ID, and the registration policy. Store the resulting credential against the server-selected account. A user handle supplied by the browser must not redirect the credential into a different account.

Attestation can provide information about authenticator provenance when a deployment actually needs and validates it. It adds privacy, interoperability, and operational tradeoffs; it is not a universal requirement for ordinary consumer passkeys. A credential’s presence alone does not prove a particular device model or management status.

Synced and device-bound credentials have different backup and recovery characteristics. WebAuthn reports them in the authenticator data: the backup eligibility (BE) flag is set for a multi-device credential and never changes after registration, and the backup state (BS) flag shows whether it is currently backed up. A synced passkey improves usability and loss recovery but adds the sync provider’s account-recovery boundary; a hardware key reduces that dependency while increasing loss and replacement burden. Store both flags and choose the assurance model explicitly instead of assuming the word passkey specifies one storage model.

## Which authenticators meet AAL2 and AAL3?

NIST SP 800-63B-4 (July 2025) requires verifiers to offer at least one phishing-resistant option at AAL2. It allows syncable authenticators at AAL2 when the sync fabric is protected with AAL2-equivalent MFA, and forbids them at AAL3 because their keys are exportable. It treats SMS and voice codes as restricted: the organization must accept the risk explicitly, tell users, and offer an alternative. The MFA guide at /domains/identity-crypto/authentication/mfa-account-takeover/ compares the other factors.

| Authenticator | AAL2 | AAL3 | Phishing-resistant |
| --- | --- | --- | --- |
| Synced passkey with user verification | Yes | No: the key is exportable | Yes |
| Device-bound passkey or security key with user verification | Yes | Yes, with a non-exportable key; federal use needs FIPS 140 validation | Yes |
| Password plus an authenticator app code (TOTP) | Yes | No | No |
| Password plus a push prompt where the user types the code shown at login | Yes; rate-limit prompts | No | No |
| Password plus an SMS or voice code | Restricted: risk acceptance and an alternative required | No | No |
| Approve-only push, or picking a number from a short list | Not acceptable to NIST | No | No |

## Consume the challenge and enforce the full assertion

During a login, retrieve the credential and its account binding, verify the response and required flags, and atomically consume the challenge. A signature over an old challenge must not establish a new session. Unknown credentials and malformed responses should fail without revealing unnecessary account information.

Signature counters can be useful anomaly signals for some authenticators, but zero or non-increasing counters are not universal proof of cloning: the specification lets authenticators keep the counter at zero, and many synced passkey providers do. Follow the specification and application risk policy rather than treating every counter value as a portable hardware guarantee.

After a valid assertion, issue a fresh local session with the recorded authentication method and time. A sensitive action can demand recent verification, even if the session is still active. Backup factors and support-mediated recovery must be reviewed because they may offer a weaker route to the same authority.

## Verify registration and sign-in in Node.js with SimpleWebAuthn

These four functions keep the security decisions on the server: a challenge stored in the session and consumed once, required user verification, the server-selected account, and the BE and BS flags. They were tested with `@simplewebauthn/server` 14.0.3 on Node.js 26 using a software authenticator that produces real ECDSA P-256 registrations and assertions: a correct sign-in succeeds once, and a replay, a missing UV flag, a phishing origin, a stale challenge and an unknown credential all fail. For manual testing in a browser, use the WebAuthn panel in Chrome DevTools to add a virtual authenticator.

```javascript
import {
  generateRegistrationOptions, verifyRegistrationResponse,
  generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const rp = {name: 'Example Accounts', id: 'accounts.example', origin: 'https://accounts.example'};

// Registration adds durable authority: call it only from a recently reauthenticated session.
export async function registrationOptions(session, user, existing) {
  const options = await generateRegistrationOptions({
    rpName: rp.name, rpID: rp.id, userName: user.email,
    userID: user.webauthnUserId, // random bytes stored with the account, never the email
    attestationType: 'none',
    excludeCredentials: existing.map(c => ({id: c.id, transports: c.transports})),
    authenticatorSelection: {residentKey: 'required', userVerification: 'required'},
  });
  session.challenge = options.challenge;
  return options; // for navigator.credentials.create() in the browser
}

export async function finishRegistration(session, user, response) {
  const expectedChallenge = session.challenge;
  delete session.challenge; // one use, even on failure
  const {verified, registrationInfo: info} = await verifyRegistrationResponse({
    response, expectedChallenge, expectedOrigin: rp.origin, expectedRPID: rp.id,
    requireUserVerification: true,
  });
  if (!verified) throw new Error('registration failed');
  return {
    userId: user.id, // the server-selected account, not a browser-supplied user handle
    ...info.credential, // id, publicKey, counter, transports
    backupEligible: info.credentialDeviceType === 'multiDevice', // BE flag: a synced passkey
    backedUp: info.credentialBackedUp, // BS flag
  };
}

export async function authenticationOptions(session) {
  const options = await generateAuthenticationOptions({rpID: rp.id, userVerification: 'required'});
  session.challenge = options.challenge;
  return options; // for navigator.credentials.get()
}

export async function finishAuthentication(session, response, findCredential) {
  const expectedChallenge = session.challenge;
  delete session.challenge;
  const credential = await findCredential(response.id);
  if (!expectedChallenge || !credential) throw new Error('authentication failed');
  const {verified, authenticationInfo} = await verifyAuthenticationResponse({
    response, expectedChallenge, expectedOrigin: rp.origin, expectedRPID: rp.id,
    credential, requireUserVerification: true,
  });
  if (!verified) throw new Error('authentication failed');
  credential.counter = authenticationInfo.newCounter;
  credential.backedUp = authenticationInfo.credentialBackedUp;
  return credential.userId; // now rotate the session and record the method and time
}

```

## Verify registration and sign-in in Python with py_webauthn

The same four steps with py_webauthn. `require_user_verification` defaults to false in both verify functions, so pass true explicitly; `credential_device_type` of `multi_device` is the BE flag and `credential_backed_up` is the BS flag. The session here is any server-side store: the challenge is bytes and must not travel in a cookie the browser can edit. Tested with `webauthn` 3.0.1 on Python 3.14 using a software authenticator that produces real ES256 registrations and assertions: a registration without user verification fails, a sign-in succeeds once, and a replayed response, a missing UV flag, a phishing origin, a wrong RP ID and an unknown credential all fail.

```python
from webauthn import (
    generate_authentication_options, generate_registration_options, options_to_json,
    verify_authentication_response, verify_registration_response,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria, CredentialDeviceType, PublicKeyCredentialDescriptor,
    ResidentKeyRequirement, UserVerificationRequirement,
)

RP_ID, RP_NAME, ORIGIN = "accounts.example", "Example Accounts", "https://accounts.example"


# Registration adds durable authority: call it only from a recently reauthenticated session.
def registration_options(session, user, existing) -> str:
    options = generate_registration_options(
        rp_id=RP_ID, rp_name=RP_NAME, user_name=user["email"],
        user_id=user["webauthn_user_id"],  # random bytes stored with the account, never the email
        exclude_credentials=[PublicKeyCredentialDescriptor(id=c["id"]) for c in existing],
        authenticator_selection=AuthenticatorSelectionCriteria(
            resident_key=ResidentKeyRequirement.REQUIRED,
            user_verification=UserVerificationRequirement.REQUIRED,
        ),
    )  # attestation defaults to "none"
    session["challenge"] = options.challenge
    return options_to_json(options)  # for navigator.credentials.create() in the browser


def finish_registration(session, user, credential_json) -> dict:
    challenge = session.pop("challenge", None)  # one use, even on failure
    if challenge is None:
        raise ValueError("no registration in progress")
    reg = verify_registration_response(
        credential=credential_json, expected_challenge=challenge, expected_rp_id=RP_ID,
        expected_origin=ORIGIN, require_user_verification=True,
    )
    return {
        "user_id": user["id"],  # the server-selected account, not a browser-supplied user handle
        "id": reg.credential_id, "public_key": reg.credential_public_key, "sign_count": reg.sign_count,
        "backup_eligible": reg.credential_device_type == CredentialDeviceType.MULTI_DEVICE,  # BE flag
        "backed_up": reg.credential_backed_up,  # BS flag
    }


def authentication_options(session) -> str:
    options = generate_authentication_options(
        rp_id=RP_ID, user_verification=UserVerificationRequirement.REQUIRED)
    session["challenge"] = options.challenge
    return options_to_json(options)  # for navigator.credentials.get()


def finish_authentication(session, credential_json, find_credential):
    challenge = session.pop("challenge", None)
    credential = find_credential(credential_json["rawId"])
    if challenge is None or credential is None:
        raise ValueError("authentication failed")
    result = verify_authentication_response(
        credential=credential_json, expected_challenge=challenge, expected_rp_id=RP_ID,
        expected_origin=ORIGIN, credential_public_key=credential["public_key"],
        credential_current_sign_count=credential["sign_count"], require_user_verification=True,
    )
    credential["sign_count"] = result.new_sign_count
    credential["backed_up"] = result.credential_backed_up
    return credential["user_id"]  # now rotate the session and record the method and time

```

## Add passkeys to a Spring Security application

Spring Security has built-in passkey support since 6.4: `webAuthn()` adds the registration and sign-in endpoints and a default registration page, and verification uses WebAuthn4J. Its default options ask for user verification as `preferred`, and in testing a registration without the UV flag was accepted, so the relying-party bean below requires it for both ceremonies. Credential records keep the BE and BS flags (`isBackupEligible`, `isBackupState`); use `JdbcPublicKeyCredentialUserEntityRepository` and `JdbcUserCredentialRepository` to persist them.

Tested with Spring Boot 4.1.1 (Spring Security 7.1.1, WebAuthn4J 0.31.9) on JDK 21 with a software authenticator: the options require user verification and a discoverable credential, registration stores both backup flags, sign-in succeeds once, and a replayed assertion, a missing UV flag and a phishing origin are rejected with 401. A registration without UV is refused with an exception from WebAuthn4J, so map it to a clear error for users.

```java
import java.util.Set;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.ResidentKeyRequirement;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;

@Configuration
class PasskeyConfig {

    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests -> requests.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults())
                // Adds /webauthn/register/options, /webauthn/register,
                // /webauthn/authenticate/options and /login/webauthn.
                .webAuthn(webAuthn -> webAuthn.rpName("Example Accounts").rpId("accounts.example")
                        .allowedOrigins("https://accounts.example"));
        return http.build();
    }

    @Bean
    WebAuthnRelyingPartyOperations relyingParty(PublicKeyCredentialUserEntityRepository users,
            UserCredentialRepository credentials) { // JDBC implementations in production
        Webauthn4JRelyingPartyOperations rp = new Webauthn4JRelyingPartyOperations(users, credentials,
                PublicKeyCredentialRpEntity.builder().id("accounts.example").name("Example Accounts").build(),
                Set.of("https://accounts.example"));
        // Spring's default is userVerification "preferred", which accepts a response without
        // the UV flag. Require it, for registration and for sign-in.
        rp.setCustomizeCreationOptions(options -> options.authenticatorSelection(
                AuthenticatorSelectionCriteria.builder()
                        .residentKey(ResidentKeyRequirement.REQUIRED)
                        .userVerification(UserVerificationRequirement.REQUIRED)
                        .build()));
        rp.setCustomizeRequestOptions(options -> options.userVerification(UserVerificationRequirement.REQUIRED));
        return rp;
    }
}

```

## Test replacement as carefully as login

An ordinary correct assertion should succeed once. Wrong challenge, origin, RP ID, credential/account binding, or missing required verification must fail. Replayed assertions must fail even when their signatures remain mathematically valid.

For replacement, prove that the user controls the account through the approved recovery or remaining-factor process, notify through established channels, and revoke lost credentials as appropriate. Test that an old stolen session cannot register a new authenticator after a recovery event. Record credential IDs and lifecycle events without recording local biometric information the server does not possess.

## Guided practice

Your relying party is `accounts.example` and requires user verification. You have the authenticator data from one registration and three sign-in attempts for alice, whose stored credential has counter 4 and was registered with BE and BS set. Decode the bytes yourself, decide each attempt, and write the registration policy. Assume the challenge, origin and signature checks pass unless the bytes show otherwise.

### Supplied material

```text
Authenticator data, hex (rpIdHash, 32 bytes | flags, 1 byte | signCount, 4 bytes | ...):
R1 registration, a new security key for bob:
  b4f8884f1db7f43d4bbe6e0aff89a7bee28ddf6344386436a4713c7fef7ab1ed 45 00000000 ...attested credential data...
A1 sign-in, alice:
  b4f8884f1db7f43d4bbe6e0aff89a7bee28ddf6344386436a4713c7fef7ab1ed 1d 00000005
A2 sign-in, alice:
  b4f8884f1db7f43d4bbe6e0aff89a7bee28ddf6344386436a4713c7fef7ab1ed 19 00000000
A3 sign-in, alice:
  a0eed2964cb5b1d486a15ca2f9ac62ff9800b6a29dac7d83783da95060d1cfa8 1d 00000006
Flag bits: 0 UP (user present), 2 UV (user verified), 3 BE (backup eligible), 4 BS (backed up), 6 AT (attested credential data), 7 ED (extensions).
```

### 1. Check the RP ID hash

Compute the SHA-256 of the RP ID and compare it with the first 32 bytes of each record.

```bash
python3 -c 'import hashlib; print(hashlib.sha256(b"accounts.example").hexdigest())'

```

Worked answer: `SHA-256("accounts.example")` is `b4f8884f1db7f43d4bbe6e0aff89a7bee28ddf6344386436a4713c7fef7ab1ed`, which matches R1, A1 and A2. A3 starts with `a0eed296...`, which is the hash of a different RP ID (it is SHA-256 of `accounts-example.com`), so A3 was made for another site and must fail.

### 2. Decode the flags

Write each flags byte in binary and list the flags it sets.

```bash
python3 -c 'names = {0: "UP", 2: "UV", 3: "BE", 4: "BS", 6: "AT", 7: "ED"}; [print(hex(f), format(f, "08b"), [n for b, n in names.items() if f >> b & 1]) for f in (0x45, 0x1d, 0x19)]'

```

Worked answer: `0x45` = `0100 0101`: UP, UV and AT, with BE and BS clear, so R1 is a device-bound credential. `0x1d` = `0001 1101`: UP, UV, BE and BS, a synced passkey that the user verified. `0x19` = `0001 1001`: UP, BE and BS but no UV.

### 3. Decide each attempt

Decide R1, A1, A2 and A3, and update the stored record where an attempt succeeds.

Worked answer: R1: register it and store `BE=0`, `BS=0`; as a device-bound key with user verification it can support AAL3 if the key is non-exportable. A1: accept; store counter 5 and `BS=1`. A2: reject for missing user verification; its counter of 0 below the stored 4 is not on its own proof of cloning, because many synced passkeys always report 0. A3: reject on the RP ID hash before looking at anything else.

### 4. Spot an impossible change

A later sign-in for bob's R1 credential arrives with flags `0x1d`. Decide what it means.

Worked answer: BE is fixed at registration, and R1 registered with BE clear; a response claiming BE set for the same credential cannot come from the registered authenticator. Reject it and investigate the credential. BS, by contrast, may change between sign-ins and should simply be updated.

### 5. Write the registration options and tests

Write the registration options JSON your server sends, and list the tests that prove the decisions above.

Worked answer: Options with `rp: {id: "accounts.example"}`, a fresh random challenge, `authenticatorSelection: {residentKey: "required", userVerification: "required"}`, `attestation: "none"` for consumer sign-in, and `excludeCredentials` listing the user's existing credentials. Tests with a software authenticator or the Chrome DevTools virtual authenticator: a verified sign-in succeeds once, a replay fails, `UV=0` fails, a response for another RP ID fails, and the stored record keeps BE and BS. The Node.js, Python and Java examples on this page were tested this way.

Deliverable: A decision table for R1 and A1 to A3 with the decoded flags and the deciding check, the registration options JSON, and a list of five tests.

Scope: The authenticator data is truncated to the fields the exercise needs and carries no signatures. Use a software authenticator or the Chrome DevTools WebAuthn panel to produce real, signed responses for the tests.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [W3C Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/). W3C Recommendation, 25 August 2026.
- [NIST SP 800-63B-4: authenticators, AALs and syncable authenticators](https://pages.nist.gov/800-63-4/sp800-63b.html). SP 800-63B-4 (July 2025).
- [OWASP Multifactor Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html)
- [SimpleWebAuthn: server documentation](https://simplewebauthn.dev/docs/packages/server)
- [OWASP ASVS 5.0: V6 Authentication](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x15-V6-Authentication.md). Version 5.0.0 (May 2025).
- [py_webauthn: WebAuthn for Python](https://github.com/duo-labs/py_webauthn)
- [Spring Security: passkeys](https://docs.spring.io/spring-security/reference/servlet/authentication/passkeys.html)