Home › Security › Authentication & authorization
guide low

Passkeys (WebAuthn)

Problem Sites with consumer or employee logins suffering from phishing, leaked password databases and weak SMS or app-code second factors. This guide covers when and how to move login to passkeys.

Phishing-resistant login with a per-site key pair held by the device or password manager; the server stores only a public key.

passkeyswebauthnfido2mfaphishing-resistantauthentication
Overview

A passkey is a key pair, created by the user's phone, laptop, security key or password manager, bound to your site's domain. Login is a signature over a server challenge; the browser includes the real origin in what's signed, so a look-alike phishing domain gets nothing usable. A database leak exposes only public keys. Retailers, health-care providers and airlines have rolled it out.

Key facts

Use when

  • Any consumer or employee login; as primary credential or replacing SMS/TOTP second factors. Reported by FIDO Alliance members: Amazon (e-commerce, 6x faster sign-in), CVS Health (health care, 98% less mobile account takeover fraud), Air New Zealand (aviation, 50% less login abandonment).

Avoid when

  • You can't offer a fallback and recovery path yet: keep passwords + MFA alongside, then migrate.

Deep dive

Decision table

SituationChoiceWhy
New consumer appPasskey-first with email-link or password fallbackHighest conversion and phishing resistance
Existing password usersOffer "create a passkey" after login; conditional UI on the login formGradual migration, no forced change
Workforce / adminsDevice-bound keys (FIDO2 security keys) with attestation, userVerification: "required"Controlled hardware, no sync to personal clouds
Second factor onlyWebAuthn as MFA, residentKey: "discouraged"Works with existing username step
Native iOS/Android appPlatform passkey APIs + associated domains / Digital Asset LinksSame credential as the website
Account recoverySeveral passkeys per account + verified email/ID recoveryLosing 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:

  1. 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"}.
  2. Browser calls navigator.credentials.create; authenticator makes a key pair and returns attestation object + clientDataJSON (type, challenge, origin).
  3. Server checks challenge, origin, rpIdHash = SHA-256(RP ID), user-present flag, and stores the credential.

Authentication:

  1. Server sends challenge, rpId, allowCredentials (empty for discoverable passkeys).
  2. Authenticator signs authenticatorData || SHA-256(clientDataJSON).
  3. 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_state flags: they tell you whether a passkey is synced, useful for recovery prompts.

Pitfalls

  • Wrong RP ID: credentials made for login.example.com don't work on example.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

namelangrepoinstallverdictlicencehealthas_ofnotes
webauthn-rsRustkanidm/webauthn-rscargo add webauthn-rs————relying-party library, used by Kanidm
passkey-rsRust1Password/passkey-rscargo add passkey————client and authenticator side, not a server library
go-webauthnGogo-webauthn/webauthngo get github.com/go-webauthn/webauthn————relying-party library
SimpleWebAuthnTypeScriptMasterKale/SimpleWebAuthnnpm install @simplewebauthn/server————server and browser packages
py_webauthnPythonduo-labs/py_webauthnpip 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

Used by

mfa · plannedMulti-factor authentication flows: TOTP, push, WebAuthn second factor, recovery codes, step-up

Systems (11):

Alternatives and related