Skip to content
ecdsa.com

Passkeys vs passwords: phishing resistance, breaches and recovery

A password is a secret the user types into whatever page asks for it. A passkey is a key pair bound to one site, and the private half never leaves the user's device or password manager.

Passkeys vs passwords compares a shared secret with public-key authentication. A password is known to the user and, in hashed form, to the server, and it works on any page that asks for it, including a fake one. A passkey is a WebAuthn credential: a key pair created for one site, unlocked on the user's device with a fingerprint, face or PIN, and used to sign a fresh challenge that is bound to the site's identity.

At a glance

PasswordPasskey
What the user provesKnowledge of a shared secretPossession of a private key, unlocked locally by biometric or PIN
What the server storesSalt and slow hash (Argon2id, scrypt, PBKDF2)Credential ID and public key (91 bytes for P-256 in the demo below)
Look-alike phishing siteReceives the password if the user types itGets nothing usable: the credential is bound to the site's RP ID and origin
Reuse across sitesCommon, which enables credential stuffingImpossible: one key pair per site and account
Database breachOffline guessing against the hashesOnly public keys leak; nothing to replay
NIST SP 800-63B-4 (July 2025)Single-factor: at least 15 characters, AAL1Multi-factor cryptographic: AAL2; device-bound hardware keys can reach AAL3, synced passkeys cannot
Lost deviceNot affectedSynced passkeys restore with the provider account; device-bound keys need a backup credential
Other devicesType it anywhereSynced across the provider's devices; QR code and Bluetooth proximity (hybrid) for others
StandardsNone beyond HTML forms and HTTP authenticationW3C WebAuthn Level 3 (Recommendation since 25 August 2026), FIDO CTAP 2.2

What happens when you sign in

At registration the authenticator (a phone, laptop, password manager or security key) creates a key pair for the site's relying party ID, for example example.com, and the site stores the public key. At sign-in the site sends a random challenge; the browser adds the page's origin to the signed client data and the authenticator signs that together with a hash of the RP ID, after user verification. The server checks the challenge, the origin, the RP ID hash and the signature.

The script below (Node.js 26.10, Apple M4 Max) shows what each approach leaves on the server and which checks stop a look-alike site.

What the server stores, and why a look-alike origin fails
import { argon2Sync, createHash, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";

const sha256 = (b) => createHash("sha256").update(b).digest();

// What a password database stores: an Argon2id hash (OWASP minimum: 19 MiB, t=2, p=1)
const params = { nonce: randomBytes(16), memory: 19456, passes: 2, parallelism: 1, tagLength: 32 };
const t0 = performance.now();
const tag = argon2Sync("argon2id", { message: "correct horse battery staple", ...params });
const ms = performance.now() - t0;
console.log(`password: stores salt + ${tag.length}-byte Argon2id hash; one guess costs ${ms.toFixed(0)} ms`);

// What a passkey database stores: the credential's public key
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
const spki = publicKey.export({ type: "spki", format: "der" });
console.log(`passkey:  stores a ${spki.length}-byte public key; a leak lets nobody sign in`);

// Authenticator: signs authenticatorData || SHA-256(clientDataJSON)
function getAssertion(rpId, origin, challenge) {
  const clientDataJSON = Buffer.from(JSON.stringify({ type: "webauthn.get", challenge, origin }));
  const authData = Buffer.concat([sha256(rpId), Buffer.from([0x05]), Buffer.alloc(4)]);
  const signature = sign("sha256", Buffer.concat([authData, sha256(clientDataJSON)]), privateKey);
  return { clientDataJSON, authData, signature };
}

// Relying party: the checks from WebAuthn §7.2
function check(a, challenge) {
  const c = JSON.parse(a.clientDataJSON);
  return {
    challenge: c.challenge === challenge,
    origin: c.origin === "https://example.com",
    rpIdHash: a.authData.subarray(0, 32).equals(sha256("example.com")),
    signature: verify("sha256", Buffer.concat([a.authData, sha256(a.clientDataJSON)]), publicKey, a.signature),
  };
}

const challenge = randomBytes(32).toString("base64url");
console.log("real site:", check(getAssertion("example.com", "https://example.com", challenge), challenge));
console.log("look-alike:", check(getAssertion("examp1e.com", "https://examp1e.com", challenge), challenge));
Output
password: stores salt + 32-byte Argon2id hash; one guess costs 25 ms
passkey:  stores a 91-byte public key; a leak lets nobody sign in
real site: { challenge: true, origin: true, rpIdHash: true, signature: true }
look-alike: { challenge: true, origin: false, rpIdHash: false, signature: true }

The look-alike case is deliberately generous to the attacker: in practice the browser refuses to use example.com credentials on another site at all, so no signature is produced. Even a signature obtained by relaying the session would carry the wrong origin and RP ID hash, which is why NIST classifies WebAuthn as phishing-resistant through verifier name binding, while passwords, one-time codes and push approvals are not. Argon2id makes each password guess cost tens of milliseconds, but users who pick common or reused passwords are still found quickly; a public key gives an attacker nothing to guess.

Where each one still fails

Passwords fail through reuse (credential stuffing with leaked lists), phishing pages and real-time phishing proxies that also capture one-time codes, and weak choices. NIST SP 800-63B-4 therefore requires at least 15 characters for single-factor passwords (8 inside multi-factor authentication), forbids composition rules and periodic changes, requires checking new passwords against breached and common values, and recommends allowing at least 64 characters. A password manager with domain-matched autofill fixes reuse and some phishing, but not every user has one.

Passkeys move the risk to two places. The first is the provider account: synced passkeys are only as safe as the Apple, Google, Microsoft or password-manager account that holds them, which is why NIST does not allow synced, exportable keys at AAL3. The second is account recovery on your site: if a lost passkey can be replaced through an e-mail link or SMS code, attackers will use that path instead. Users also need a second credential or a synced provider to avoid lockout.

When to choose which

  • Consumer sites and apps: passkeys as the primary method, created right after the next password sign-in; passwords optional.
  • Workforce sign-in: passkeys through your identity provider; device-bound security keys for administrators and other AAL3 roles.
  • High-value consumer accounts (banking, e-mail, cloud consoles): passkeys plus a strong recovery process, ideally a second registered passkey.
  • Shared or kiosk devices: hybrid sign-in from the user's phone rather than storing credentials on the shared device.
  • Protocols without WebAuthn (IMAP, legacy VPNs, SSH): app-specific passwords or OAuth flows that start in a browser where passkeys work.

Migration notes

  1. Add passkeys next to passwords: after a successful password sign-in, offer to create a passkey, or use WebAuthn Level 3 conditional creation to add one automatically when the browser's password manager supports it.
  2. Use the autofill UI: call navigator.credentials.get with mediation: "conditional" on the sign-in page so passkeys appear in the username field's suggestions.
  3. Store what you need: credential ID, public key, signature counter, transports, the backup flags and the AAGUID that names the provider; the AAGUID lookup maps it to a product.
  4. Fix recovery before removing passwords: require an existing passkey, a second registered passkey or identity verification, not just an e-mail link.
  5. Keep providers in sync with the Level 3 signal methods when users delete credentials or change names, and declare related origins if you sign in across several domains.
  6. Test in the passkey sandbox and inspect real responses with the WebAuthn inspector before rollout.

References

All comparisons