UnveilTech

UnveilScan Blog

← All articles

Try UnveilScan free

WebAuthn / Passkeys: deployment in 2026

Posted 2026-04-29 · 9 min read · authadvanced

Passkeys reached 2 billion accounts across Apple/Google/Microsoft by 2025-Q4. WebAuthn-as-second-factor was the 2019 narrative; passkey-as-primary-credential is the 2026 reality. Banks, marketplaces, social, SaaS — everyone has either shipped or is shipping. Most deployments still get the same handful of details wrong.

Synced vs device-bound

A passkey is a WebAuthn credential. The new variation in 2024-2025: synced passkeys (via iCloud Keychain, Google Password Manager, 1Password, Bitwarden) flow between a user's devices. A user's iPhone passkey appears on their MacBook automatically. Great UX, weaker assertion.

TypeWhere the private key livesUXThreat model
Device-boundHardware (Yubikey, TPM, Secure Enclave) — never extractedLost device = lost credentialPhishing-resistant. Account-compromise-resistant.
Synced (consumer)Cloud-encrypted across devices via E2EELost device = log into iCloud on new onePhishing-resistant. Cloud-account-compromise = credential compromise.

For consumer apps, synced is correct — recovery is the dominant failure mode of device-bound. For high-security workflows (banking root admin, IT operator, journalists with adversarial context), require device-bound and validate via attestation.

Attestation: skip it for consumer, demand it for enterprise

During registration, the authenticator can attest to its make/model. This is how you verify "this credential lives in a Yubikey 5C, not in a browser-based software authenticator." Apple, Google, and Microsoft do not attest synced passkeys (privacy-preserving) — you'll get an attestationFormat of "none" or "packed" with no chain.

The mistake: requesting attestation: "direct" for consumer flows. This triggers a "give this site information about your authenticator" prompt that confuses users. Use attestation: "none" for consumer.

For enterprise device-binding, attestation: "direct" + verify against an MDS3 (FIDO Metadata Service) entry confirming the AAGUID matches an approved authenticator model.

RP ID scoping

The Relying Party ID (RP ID) is the domain that "owns" the passkey. WebAuthn enforces: the JS calling navigator.credentials.create() must be on a page whose origin's effective TLD+1 matches the RP ID.

Common mistakes:

Account recovery: where deployments fall apart

Lost passkey = locked out. Three failure-domains:

Recovery patterns that work:

  1. Multiple credentials at registration. "Add a backup passkey on a different device" prompt during onboarding. Most users skip — make it as low-friction as possible.
  2. Recovery codes. Generated server-side, displayed once, expected to be stored in 1Password. 8-10 codes, each single-use. Standard pattern.
  3. Identity-verified support. User contacts support, proves identity through a separate channel (KYC for banking, ID document upload for SaaS). Support resets the auth. High operational cost; necessary for high-stakes accounts.

What does not work: SMS recovery. Defeats the phishing-resistance you bought by deploying passkeys. Email recovery is also weak if the user's email account isn't itself passkey-protected.

The conditional-mediation flow

In 2024 conditional UI / autofill landed. Browsers can suggest passkeys in the password field's autofill chip. The login page no longer needs an explicit "Sign in with passkey" button — the user types their email, and a passkey suggestion appears alongside saved passwords.

// On page load, after the username input is rendered
const cred = await navigator.credentials.get({
  mediation: 'conditional',
  publicKey: { challenge: serverChallenge, rpId: 'example.com', userVerification: 'preferred' }
});
// Browser shows the passkey in autofill UI
// When user picks one, this resolves with the assertion

Massive UX win. Implementation gotcha: the conditional call hangs until the user picks. Don't await it on the critical render path — fire-and-forget at page load, handle the resolution in an event listener.

The bug we still see

During scans we sometimes find login pages that advertise WebAuthn but fall back to password-only on the same form, with no autocomplete="webauthn" hint. These pages registered the user's passkey but never use conditional UI to surface it. User logs in with their password, never sees their passkey, eventually deletes it. The feature was deployed but not utilized.

Add autocomplete="username webauthn" on your username input so browsers know to surface passkey suggestions. Without that hint, conditional-mediation support varies by browser.

What we check externally

From outside, we can't observe registration flows. We can detect: autocomplete="webauthn" on visible login forms (positive signal); the presence of navigator.credentials in JS bundles (informational); WebAuthn endpoints under /auth/webauthn/ or /api/v1/webauthn/ discovered via api_surface. We don't currently surface these as findings — they're informational signals during recon.

Audit your auth surface

Free Basic scan covers cookie flags + headers. Extended adds API surface mapping that often reveals the WebAuthn paths.

Run a scan