WebAuthn / Passkeys: deployment in 2026
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.
| Type | Where the private key lives | UX | Threat model |
|---|---|---|---|
| Device-bound | Hardware (Yubikey, TPM, Secure Enclave) — never extracted | Lost device = lost credential | Phishing-resistant. Account-compromise-resistant. |
| Synced (consumer) | Cloud-encrypted across devices via E2EE | Lost device = log into iCloud on new one | Phishing-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:
- RP ID = full subdomain. If you set
rpId: "login.example.com", that passkey only works onlogin.example.com.www.example.comandapp.example.comwon't see it. Generally userpId: "example.com"for cross-subdomain compatibility. - RP ID on a public suffix. Cannot be a public suffix (
github.io,vercel.app) — would let one tenant's passkey work for another. Use a real custom domain for production WebAuthn. - Inconsistent across registration and assertion. Register on
example.com, then assert onapp.example.comwithrpId: "app.example.com"— fails. Pick once, stick to it.
Account recovery: where deployments fall apart
Lost passkey = locked out. Three failure-domains:
- Device replacement. User upgrades phone. Device-bound key is gone. Synced passkey appears on the new device automatically (iCloud/Google) — usually transparent.
- Cloud account loss. User forgets Apple ID password. Synced passkeys gone. They need a recovery channel.
- Sole authenticator lost. User's only Yubikey lost. No other auth on the account. Without recovery: dead account.
Recovery patterns that work:
- 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.
- Recovery codes. Generated server-side, displayed once, expected to be stored in 1Password. 8-10 codes, each single-use. Standard pattern.
- 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