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.
| 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 |
- Register a passkey with the Chrome DevTools virtual authenticator and confirm the stored record has the account ID, public key, counter, BE and BS.
- Replay a captured sign-in response: it must fail because its challenge was consumed.
- Sign in with user verification turned off in the virtual authenticator: the server must reject it.
- From a session older than the reauthentication window, try to add a passkey: the server must require authentication first.
- Exceed the push or SMS attempt limit and confirm further prompts stop until the documented reset.
- Applies to
- W3C Web Authentication Level 3 (Recommendation, August 2026)
- @simplewebauthn/server 14.x (tested with 14.0.3)
Passkey and MFA enrollment, sign-in and replacement for web and mobile applications. Recovery can undermine strong primary authentication.
- Part of
- Learning path: Identity through the whole lifecycle (step 5 of 14). Workflows: Fix a finding, Secure a feature
Learning goals and prerequisites
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.
Before you start
Developers adding passkeys or other MFA to an account system, and reviewers who check enrollment, assurance levels and recovery.
- Distinguish account identity, credential enrollment, user verification and fallback recovery.
Refresh first: Session management: cookies, timeouts, rotation and revocation
Go to the guided practiceRegister a passkey, then sign in with it
- Start registrationFrom a recently reauthenticated session, the server sends options with a single-use challenge, the RP ID, the account’s user handle and
userVerificationset torequired, and keeps the challenge with the session. - Create the credentialThe browser calls
navigator.credentials.create(). After the user verifies with a PIN or biometric, the authenticator makes a new key pair for the RP ID and returns the credential ID, public key and authenticator data, which the browser sends with itsclientDataJSON. - Verify and storeThe server consumes the challenge and checks the origin, the RP ID hash, the UP and UV flags and its attestation policy, then stores the credential ID, public key, counter and BE and BS flags against the server-selected account.
- Start sign-inThe server sends a new single-use challenge with the RP ID, plus
allowCredentialswhen the user is already known. - Sign the challengeThe browser calls
navigator.credentials.get(). After user verification, the authenticator signs its authenticator data and the hash ofclientDataJSONwith the private key, which never leaves it. - Verify and start a sessionThe server finds the credential, consumes the challenge, verifies the signature with the stored public key, checks the origin, RP ID hash and flags, updates the counter and BS flag, and issues a fresh session.
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.
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.
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 |
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.
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.
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
}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.
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 timeSpring 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.
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;
}
}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 scenario material
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.
Shellpython3 -c 'import hashlib; print(hashlib.sha256(b"accounts.example").hexdigest())'Expected result and reasoning
SHA-256("accounts.example")isb4f8884f1db7f43d4bbe6e0aff89a7bee28ddf6344386436a4713c7fef7ab1ed, which matches R1, A1 and A2. A3 starts witha0eed296..., which is the hash of a different RP ID (it is SHA-256 ofaccounts-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.
Shellpython3 -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)]'Expected result and reasoning
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.
Decide each attempt
Expected result and reasoning
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 andBS=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.Expected result and reasoning
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.
Expected result and reasoning
Options with
rp: {id: "accounts.example"}, a fresh random challenge,authenticatorSelection: {residentKey: "required", userVerification: "required"},attestation: "none"for consumer sign-in, andexcludeCredentialslisting 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=0fails, 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.
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.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- W3C Web Authentication Level 3. W3C Recommendation, 25 August 2026.
- NIST SP 800-63B-4: authenticators, AALs and syncable authenticators. SP 800-63B-4 (July 2025).
- OWASP Multifactor Authentication Cheat Sheet
- SimpleWebAuthn: server documentation
- OWASP ASVS 5.0: V6 Authentication. Version 5.0.0 (May 2025).
- py_webauthn: WebAuthn for Python
- Spring Security: passkeys
Reference information
- Tags
- MFA and passkeys
- SDLC phase
- Development
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-6.3.3 · v5.0.0-6.4.3 · v5.0.0-6.5.1 · v5.0.0-6.6.1 · v5.0.0-6.6.4 · v5.0.0-6.7.2