Deep dive
Decision table
| Situation | Choice | Why |
|---|---|---|
| New consumer app | Passkey-first with email-link or password fallback | Highest conversion and phishing resistance |
| Existing password users | Offer "create a passkey" after login; conditional UI on the login form | Gradual migration, no forced change |
| Workforce / admins | Device-bound keys (FIDO2 security keys) with attestation, userVerification: "required" | Controlled hardware, no sync to personal clouds |
| Second factor only | WebAuthn as MFA, residentKey: "discouraged" | Works with existing username step |
| Native iOS/Android app | Platform passkey APIs + associated domains / Digital Asset Links | Same credential as the website |
| Account recovery | Several passkeys per account + verified email/ID recovery | Losing the last key must not lock users out forever |
How it works
Parts:
- Relying party (your server) — issues challenges, verifies signatures, stores credential ID, public key, counter, user handle.
- Browser / OS — enforces the RP ID against the page origin and puts the origin in
clientDataJSON. - Authenticator — phone, laptop, security key or password manager; holds the private key, signs after user verification.
- Challenge — single-use random value tying a response to one ceremony.
RP server ──challenge──► browser ──► authenticator (private key)
RP server ◄─ signature over authData || SHA-256(clientDataJSON) ◄─┘
Registration:
- Server sends options:
challenge,rp {id, name},user {id: random handle, name},pubKeyCredParams(ES256 = -7, EdDSA = -8, RS256 = -257),excludeCredentials,authenticatorSelection {residentKey: "required", userVerification: "preferred"}. - Browser calls
navigator.credentials.create; authenticator makes a key pair and returns attestation object +clientDataJSON(type, challenge, origin). - Server checks challenge, origin,
rpIdHash= SHA-256(RP ID), user-present flag, and stores the credential.
Authentication:
- Server sends
challenge,rpId,allowCredentials(empty for discoverable passkeys). - Authenticator signs
authenticatorData || SHA-256(clientDataJSON). - Server verifies signature with stored public key, challenge, origin, rpIdHash, flags (UP, UV when required), and counter.
Code
Rust server with webauthn-rs = "0.5" (does all the checks above). Keep the *_state values in the server-side session between start and finish.
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::*;
pub fn build() -> Webauthn {
let origin = Url::parse("https://example.com").unwrap();
WebauthnBuilder::new("example.com", &origin) // RP ID, allowed origin
.unwrap()
.rp_name("Example")
.build()
.unwrap()
}
// 1. Registration start: return `ccr` as JSON to the browser
pub fn reg_start(w: &Webauthn, user_id: Uuid, name: &str, existing: Vec<CredentialID>)
-> Result<(CreationChallengeResponse, PasskeyRegistration), WebauthnError> {
w.start_passkey_registration(user_id, name, name, Some(existing)) // exclude already-registered
}
// 2. Registration finish: persist the returned Passkey (serde-serializable) for the user
pub fn reg_finish(w: &Webauthn, resp: &RegisterPublicKeyCredential, st: &PasskeyRegistration)
-> Result<Passkey, WebauthnError> {
w.finish_passkey_registration(resp, st)
}
// 3. Login start with the user's passkeys
pub fn auth_start(w: &Webauthn, keys: &[Passkey])
-> Result<(RequestChallengeResponse, PasskeyAuthentication), WebauthnError> {
w.start_passkey_authentication(keys)
}
// 4. Login finish: update stored counters / backup flags, then create a session
pub fn auth_finish(w: &Webauthn, resp: &PublicKeyCredential, st: &PasskeyAuthentication,
keys: &mut [Passkey]) -> Result<CredentialID, WebauthnError> {
let result = w.finish_passkey_authentication(resp, st)?;
for k in keys.iter_mut() {
k.update_credential(&result); // no-op unless this credential changed
}
Ok(result.cred_id().clone())
}
Browser (conditional UI: passkeys appear in the username field's autofill):
<input name="username" autocomplete="username webauthn">
<script>
async function passkeyLogin() {
if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return;
const opts = await (await fetch("/auth/start", { method: "POST" })).json();
const cred = await navigator.credentials.get({
mediation: "conditional",
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(opts.publicKey),
});
await fetch("/auth/finish", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(cred.toJSON()) });
}
</script>
parseRequestOptionsFromJSON/toJSON are WebAuthn Level 3 and in current browsers; for older ones, convert base64url fields by hand or use @github/webauthn-json or SimpleWebAuthn.
Efficiency
- Cost: server side one ES256 or Ed25519 verification (order of tens of µs) plus SHA-256, against order of 10-100 ms per Argon2id login; two HTTP round trips per ceremony. Bound: round trips and user interaction.
- Levers:
- Conditional UI with discoverable credentials — drops the username step, one round trip fewer — login pages.
- Attestation
"none"— skips attestation-chain and metadata lookups — consumer apps. - Challenge in the pre-login session or short-TTL cache — no database write per attempt — always.
- Credential-ID index — one lookup per login; store only public key, counter, flags — always.
- Naive → standard → state of the art:
- Naive · password plus SMS code · hash cost plus an external call
- Standard · password plus TOTP, Argon2id
- State of the art · synced passkeys, conditional UI, no stored secret
Implementation notes
- Attestation: request
"none"for consumer apps (privacy, fewer failures); verify attestation only when you must restrict authenticator models. - User verification:
"preferred"for consumer login;"required"when the passkey is the only factor for high-risk actions, and check the UV flag. - Cross-device login works through the hybrid transport (QR code + Bluetooth proximity), so desktop users can sign in with a phone passkey.
- Several domains for one service: the Related Origin Requests list (
/.well-known/webauthn) lets other origins use the same RP ID where browsers support it. - Keep
backup_eligible/backup_stateflags: they tell you whether a passkey is synced, useful for recovery prompts.
Pitfalls
- Wrong RP ID: credentials made for
login.example.comdon't work onexample.com; choose the parent domain up front, it can't be changed later. - Not checking origin when verifying by hand (the whole phishing protection). Use a library.
- Reusing challenges, or keeping them client-side.
- Treating sign counter 0 as cloned: synced passkeys always report 0. Only flag a counter that goes backwards from a non-zero value.
- Using email as
user.id: it's exposed to authenticators and changes; use a random 16-64 byte handle. - Only one passkey per account and no recovery: support several and list them in settings.
- Recovery flows weaker than the login (SMS reset) re-open phishing.
In production
- E-commerce: Amazon reports 6x faster sign-ins with passkeys.
- Health care: CVS Health reports a 98% reduction in mobile account takeover fraud.
- Aviation: Air New Zealand reports a 50% reduction in login abandonment.
- Platforms: iCloud Keychain, Google Password Manager and 1Password sync passkeys across devices.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| webauthn-rs | Rust | kanidm/webauthn-rs | cargo add webauthn-rs | — | — | — | — | relying-party library, used by Kanidm |
| passkey-rs | Rust | 1Password/passkey-rs | cargo add passkey | — | — | — | — | client and authenticator side, not a server library |
| go-webauthn | Go | go-webauthn/webauthn | go get github.com/go-webauthn/webauthn | — | — | — | — | relying-party library |
| SimpleWebAuthn | TypeScript | MasterKale/SimpleWebAuthn | npm install @simplewebauthn/server | — | — | — | — | server and browser packages |
| py_webauthn | Python | duo-labs/py_webauthn | pip install webauthn | — | — | — | — | relying-party library |
Sources
- W3C, Web Authentication: An API for accessing Public Key Credentials, Level 2, 2021. spec, and Level 3 drafts. spec
- FIDO Alliance, Client to Authenticator Protocol (CTAP) 2.1. spec
- webauthn-rs project documentation (kanidm/webauthn-rs). docs · code
- passkeys.dev developer guidance (FIDO Alliance and W3C community). site
- FIDO Alliance, Passkeys (adoption figures from Amazon, CVS Health, Air New Zealand, Google). site