Security

WebAuthn & Passkeys

Login with a key your phone will never hand over

WebAuthn and passkeys replace passwords with a public-key credential bound to your device and unlocked by a biometric: the private key never leaves the authenticator, so there is nothing to phish, breach, or reuse.

  • Secret stored on serverPublic key only
  • Phishing resistanceOrigin-bound
  • Default signatureES256 (P-256)
  • Challenge entropy≥ 16 bytes
  • Round trips to log in1 (challenge → sign)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

The password problem, in one sentence

A password is a shared secret. You know it, and the server knows it — which means the server has to store it, you have to remember it, and it has to travel across the wire every time you log in. Every one of those facts is a vulnerability. The server's copy gets breached (8.4 billion passwords were dumped in the 2021 "RockYou2021" compilation alone). The wire copy gets phished by a lookalike site. The you-remember-it copy gets reused across forty accounts, so one leak unlocks the rest.

Public-key cryptography dissolves the whole category. Instead of a secret both sides know, your device holds a private key it never reveals, and the server holds only the matching public key. To log in, the server sends a random challenge; your device signs it with the private key; the server verifies the signature against the public key it stored at registration. The server never learns a secret, so there is nothing in its database worth stealing. That is the entire idea behind WebAuthn, and a passkey is the consumer-friendly name for the credential it produces.

WebAuthn is a 2019 W3C standard (it reached Recommendation status in March 2019, with Level 2 in 2021 and Level 3 in progress). It is the browser-facing half of FIDO2, the alliance standard pushed by Apple, Google, Microsoft, and the FIDO Alliance. The 2022 announcement that all three platforms would sync passkeys across devices is what turned a niche security-key feature into something your grandmother can use.

The two ceremonies: registration and authentication

WebAuthn defines exactly two operations, and the spec literally calls them ceremonies. Both are mediated by the browser, which sits between the website (the relying party, or RP) and the authenticator (the secure chip in your phone or the USB key in your pocket).

Registration (navigator.credentials.create()). The server sends a challenge and its identity (rp.id, e.g. example.com). The authenticator generates a brand-new key pair scoped to that exact RP ID, stores the private key in hardware, and returns the public key plus a credentialId. The server saves (userId, credentialId, publicKey, signCount).

Authentication (navigator.credentials.get()). The server sends a fresh challenge. The authenticator verifies the user (a fingerprint, a face, or a PIN), then signs a blob built from the challenge and the origin. The server verifies that signature with the stored public key. One round trip, no secret transmitted.

The cryptographic heart of authentication is what gets signed. The authenticator concatenates two byte arrays and signs the pair:

signature = Sign(privateKey,  authenticatorData || SHA-256(clientDataJSON))

where clientDataJSON contains the challenge, the origin the browser saw in the address bar, and the operation type. Because the origin is baked into the signed bytes by the browser, a phishing site at examp1e.com cannot get a valid signature for example.com — the origins won't match and verification fails. The user is never given the chance to "click through" the warning, because there is no warning to click: the check is mechanical.

The precise mechanism, byte by byte

Let's make the data structures concrete, because the security argument lives in the details. After authentication, the authenticator returns an AuthenticatorAssertionResponse with three fields the server inspects:

  • clientDataJSON — UTF-8 JSON: {type, challenge, origin, crossOrigin}. The browser fills this; the script can't forge the origin.
  • authenticatorData — a binary struct: 32-byte SHA-256 of the RP ID (the rpIdHash), 1 flags byte (User Present, User Verified bits), then a 4-byte big-endian signature counter.
  • signature — the ECDSA (or RSA / EdDSA) signature over authenticatorData || SHA-256(clientDataJSON).

Server-side verification is a fixed checklist. Each step is cheap — the only real cost is one signature verification, which on a P-256 curve is on the order of tens of microseconds:

  1. Decode clientDataJSON. Assert type === "webauthn.get", the challenge equals the one you issued (and hasn't been used), and the origin is on your allow-list.
  2. Assert authenticatorData.rpIdHash === SHA-256(rpId).
  3. Assert the User Present bit is set (and User Verified, if you required it).
  4. Verify the signature over authenticatorData || SHA-256(clientDataJSON) with the stored public key.
  5. If the returned signCount is greater than zero, assert it is strictly greater than the stored one; then persist the new value.

The algorithm itself is negotiated at registration through a list of COSE algorithm identifiers in pubKeyCredParams. The two you'll see in practice are -7 (ES256: ECDSA over NIST P-256 with SHA-256) and -257 (RS256: RSASSA-PKCS1-v1_5 with SHA-256), with -8 (EdDSA / Ed25519) increasingly supported. ES256 is the de-facto default because P-256 keys are tiny (a 65-byte uncompressed point) and verification is fast.

When to reach for passkeys — and when not to

  • Any consumer login. Passkeys remove the password reset funnel (the single biggest source of support tickets) and kill credential-stuffing outright.
  • High-value accounts. Banking, healthcare, admin consoles — phishing resistance is the whole point, and these are the targets attackers actually pursue.
  • Step-up authentication. Even if you keep passwords, a WebAuthn second factor is dramatically stronger than SMS OTP, which is trivially SIM-swapped and phishable in real time.

Where passkeys get awkward: shared accounts (a passkey is bound to one person's biometric, so the "everyone uses the streaming login" pattern breaks), account recovery (you must design a fallback before someone loses every device), and cross-ecosystem portability (a passkey synced in Apple's keychain didn't, for years, easily move to Google's — the FIDO Credential Exchange spec is only now standardizing export). Don't ship passkey-only if your users live on locked-down corporate machines with no platform authenticator and no security keys.

Passkeys vs the other ways to log in

Passkey (WebAuthn)PasswordPassword + TOTPSMS OTPMagic link
Secret stored on serverNone (public key)Hash of passwordHash + TOTP seedPhone numberEmail address
Phishing-resistantYes (origin-bound)NoNo (real-time relay)NoPartially
Survives DB breachYesIf well-hashed, slowlyShared seed leaksn/an/a
Replay-resistantYes (fresh challenge)No30s window~5 min windowUntil link expires
Works offlineYesYesYesNo (needs SMS)No (needs email)
User frictionOne biometric tapType + rememberType + app + typeWait + type codeSwitch to inbox

The decisive column is phishing-resistant. TOTP and SMS feel like security upgrades, but a modern phishing kit (Evilginx-style reverse proxy) relays the one-time code to the real site in real time and steals the resulting session cookie — the second factor doesn't help. WebAuthn breaks that attack because the signature is cryptographically pinned to the origin the browser actually loaded.

What the numbers actually say

  • Phishing reduction is total, not incremental. Google moved 85,000+ employees to FIDO security keys in 2017 and reported zero account takeovers via phishing in the years since — the headline result that made the industry take WebAuthn seriously.
  • Login is faster — and more often succeeds. Google's 2023 passkey rollout measured an average sign-in time of about 14.9 seconds with passkeys vs 30.4 seconds with passwords, and a sign-in success rate of 63.8% with passkeys vs just 13.8% with passwords — roughly four times higher.
  • The key is tiny. A P-256 public key is a 65-byte point (or 33 bytes compressed); a signature is ~71 bytes DER-encoded. A password hash like bcrypt is ~60 bytes — so storage is a wash, but the passkey side stores nothing exploitable.
  • Verification is cheap. One P-256 ECDSA verify is roughly 30–60 µs on a modern server core, so a single box verifies tens of thousands of logins per second without breaking a sweat.
  • The challenge must have real entropy. The spec mandates at least 16 random bytes (128 bits) for the challenge; below that, an attacker could conceivably precompute or guess, reopening replay.

JavaScript: the browser-side ceremonies

This is the client code, almost verbatim from the WebAuthn API. Note that the server sends the challenge as base64url and you must decode it to an ArrayBuffer before passing it in — passing a string silently breaks the signature.

const b64urlToBuf = (s) =>
  Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)).buffer;

// ── Registration ──────────────────────────────────────────────
async function registerPasskey(options) {
  // `options` comes from the server (challenge, rp, user, pubKeyCredParams...)
  const cred = await navigator.credentials.create({
    publicKey: {
      challenge: b64urlToBuf(options.challenge),
      rp: { id: 'example.com', name: 'Example' },
      user: {
        id: b64urlToBuf(options.userId),   // opaque user handle, NOT the email
        name: '[email protected]',
        displayName: 'Ada Lovelace',
      },
      pubKeyCredParams: [
        { type: 'public-key', alg: -7 },     // ES256 (P-256)
        { type: 'public-key', alg: -257 },   // RS256 fallback
      ],
      authenticatorSelection: {
        residentKey: 'required',             // make it a discoverable passkey
        userVerification: 'preferred',
      },
      timeout: 60000,
    },
  });
  // Send cred.response.{clientDataJSON, attestationObject} back to the server.
  return cred;
}

// ── Authentication ────────────────────────────────────────────
async function loginWithPasskey(options) {
  const assertion = await navigator.credentials.get({
    publicKey: {
      challenge: b64urlToBuf(options.challenge),
      rpId: 'example.com',
      // allowCredentials empty => let the platform offer any discoverable passkey
      allowCredentials: [],
      userVerification: 'preferred',
      timeout: 60000,
    },
  });
  // POST assertion.response.{clientDataJSON, authenticatorData, signature, userHandle}
  return assertion;
}

Two details that bite everyone. First, rp.id must be the site's registrable domainexample.com, never https://example.com and never a path; getting it wrong throws a SecurityError. Second, leaving allowCredentials empty is what enables the "tap any passkey" flow — but it only works if the credential was registered with residentKey: 'required'.

Python: server-side assertion verification

The server's job is the verification checklist. Here it is in Python with the cryptography library, showing the load-bearing ECDSA check. (In production, use a vetted library such as py_webauthn; this is the algorithm laid bare.)

import hashlib, json, struct
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature

def verify_assertion(public_key, client_data_json, authenticator_data,
                     signature, expected_challenge, expected_origin,
                     rp_id, stored_sign_count):
    # 1. Validate clientDataJSON
    cdata = json.loads(client_data_json)
    assert cdata["type"] == "webauthn.get",        "wrong ceremony type"
    assert cdata["challenge"] == expected_challenge, "challenge mismatch / replay"
    assert cdata["origin"]    == expected_origin,    "origin mismatch / phishing"

    # 2. rpIdHash = first 32 bytes of authenticatorData
    rp_id_hash = authenticator_data[:32]
    assert rp_id_hash == hashlib.sha256(rp_id.encode()).digest(), "rpId mismatch"

    # 3. Flags byte (offset 32): bit0 = User Present, bit2 = User Verified
    flags = authenticator_data[32]
    assert flags & 0x01, "user not present"

    # 4. Verify the signature over authenticatorData || SHA256(clientDataJSON)
    client_hash = hashlib.sha256(client_data_json).digest()
    signed = authenticator_data + client_hash
    digest = hashlib.sha256(signed).digest()
    try:
        public_key.verify(signature, digest,
                          ec.ECDSA(Prehashed(hashes.SHA256())))
    except InvalidSignature:
        raise AssertionError("bad signature")

    # 5. Signature counter: must strictly increase (0 means a synced passkey)
    new_count = struct.unpack(">I", authenticator_data[33:37])[0]
    if new_count != 0 and new_count <= stored_sign_count:
        raise AssertionError("possible cloned authenticator")

    return new_count  # persist this as the new stored_sign_count

The asymmetry with passwords is the whole story: there is no secret on this server to verify against. The function takes a public key, and even a full read of the auth database hands an attacker nothing they can replay, because every login signs a brand-new challenge.

Variants and the vocabulary you'll hit

Platform vs roaming authenticators. A platform authenticator is built into the device (Touch ID, Windows Hello, Android's StrongBox). A roaming authenticator is a separate hardware key (YubiKey, Titan) reached over USB/NFC/Bluetooth via the CTAP2 protocol. Platform authenticators are what make synced passkeys possible.

Synced vs device-bound passkeys. A synced passkey replicates through your platform's encrypted cloud (iCloud Keychain, Google Password Manager) so it survives a lost phone. A device-bound passkey never leaves its hardware — higher assurance, but lose the device and that credential is gone. Enterprises sometimes mandate device-bound for the strongest guarantee.

Attestation. At registration the authenticator can include a signed statement vouching for its make and model (e.g. "this really is a genuine YubiKey 5"). Consumer sites usually request attestation: 'none' for privacy; regulated environments request 'direct' and check the certificate chain against the FIDO Metadata Service.

Discoverable (resident) vs non-discoverable. A discoverable credential stores the user handle on the authenticator, enabling username-less login. A non-discoverable credential is stateless on the authenticator — the server must supply the credential ID in allowCredentials, so the user must identify first. Passkeys are discoverable by definition.

Conditional UI (autofill). Passing mediation: 'conditional' to credentials.get() lets the browser surface passkeys directly in the username field's autofill dropdown, so the login form works for both passkey and password users without a separate button.

Common bugs and edge cases

  • RP ID mismatch. Setting rp.id to 'https://example.com' or 'www.example.com' when the page is served from the apex domain throws SecurityError. The RP ID must be a registrable suffix of the origin.
  • Forgetting to verify the origin server-side. The browser pins it into clientDataJSON, but if your backend never checks it, you've thrown away the phishing resistance you paid for.
  • Reusing a challenge. Challenges must be single-use and time-bound. Cache them server-side and delete on first use, or a captured request replays.
  • Mishandling the signature counter. Many synced passkeys report a counter of 0 forever. If you require a strictly increasing counter unconditionally, you lock those users out — only enforce the check when the counter is nonzero.
  • Putting the email in user.id. The user handle should be an opaque, stable, non-PII byte string (≤ 64 bytes). If you ever change the email, an email-derived handle orphans the passkey.
  • No recovery path. If a user registered exactly one device-bound passkey and lost it, account-less recovery is impossible by design. Always register a second authenticator or provide a vetted recovery flow.
  • Treating User Verified as guaranteed. If you set userVerification: 'preferred', the UV flag may be unset; only 'required' guarantees a biometric/PIN actually happened.

Frequently asked questions

What actually leaves my device when I create a passkey?

Only the public key, a credential ID, and (optionally) an attestation statement. The private key is generated inside the authenticator's secure hardware and never leaves it. The server stores the public key, so a database breach leaks something useless to an attacker — there is no shared secret to steal.

Why are passkeys phishing-resistant when passwords are not?

The browser ties every signature to the origin (the relying party ID) and to a server-issued random challenge. A passkey for example.com will not sign a challenge from examp1e.com, because the origin baked into clientDataJSON would not match. The user can't be tricked into signing for the wrong site, since the browser — not the user — enforces the origin check.

What's the difference between WebAuthn, FIDO2, and CTAP2?

FIDO2 is the umbrella standard. WebAuthn is the browser-facing JavaScript API (navigator.credentials.create and .get), standardized by the W3C. CTAP2 (Client to Authenticator Protocol) is the lower-level protocol the browser uses to talk to an external authenticator like a security key over USB, NFC, or Bluetooth. A platform authenticator built into your phone skips CTAP2 and talks to the OS directly.

What is a discoverable (resident) credential and why does it matter?

A discoverable credential stores the user handle and credential ID inside the authenticator itself, so the site can offer 'just tap to sign in' with no username typed first. Non-discoverable credentials are stateless: the server must send the credential ID in allowCredentials, so the user must identify themselves first. Passkeys are, by definition, discoverable credentials that also sync across a user's devices.

How does the server stop a replay attack with a stolen signature?

Every authentication signs a fresh server-generated challenge of at least 16 random bytes, so a captured signature is worthless for the next login. The authenticator also returns a monotonically increasing signature counter; if the server ever sees a counter that did not increase, it can flag a possible cloned authenticator. Synced passkeys often report a counter of zero, which the spec permits.

If I lose my phone, do I lose my passkeys?

Not for synced passkeys — they live in your platform's encrypted credential cloud (Apple iCloud Keychain, Google Password Manager, or a third-party manager) and restore to a new device after you authenticate to that account. Device-bound passkeys on a hardware security key are not synced, which is why best practice is to register at least two authenticators so losing one is recoverable.