You are viewing a preview of this lesson. Sign in to start learning
Back to Authentication & Identity Fundamentals (2026)

Passkeys & WebAuthn

The headline 2026 hub: passwordless is now the default posture. Master the full WebAuthn stack from ceremonies to enterprise rollout.

Last generated

Why Passwords Are Being Replaced — and What Comes Next

Every developer who has worked on a user-facing system has encountered the same moment: the security audit, the breach notification email, or the 3 a.m. page that starts with "we think credentials were leaked." Passwords have been the default authentication primitive for decades, and for decades we have been building increasingly elaborate scaffolding around them — hashing algorithms, rate limiters, breach-detection APIs, forced rotation policies — trying to compensate for a design that was never well-suited to the threat landscape it now faces. Passkeys and WebAuthn are not another layer of scaffolding. They represent a genuine structural break: a different model of what authentication is and where secrets live.

The Three Ways Passwords Fail

Password-based authentication has three distinct failure modes. They are often conflated, which leads to mitigations that address one while leaving the others wide open.

Credential stuffing exploits the most predictable behavior in password-based systems: users reuse passwords across sites. When Site A suffers a breach and an attacker obtains email-and-password pairs, the first thing they do is run those pairs against Sites B, C, and D. The attack requires no vulnerability in those other sites — only reuse, which a substantial fraction of users practice.

Breached Site A
┌───────────────┐
│ user@x.com    │ ──► Attacker extracts list
│ password123   │
└───────────────┘
         │
         ▼
  [Automated stuffing tool]
         │
    ┌────┴────┐
    ▼         ▼
  Site B    Site C
  ✅ Login  ✅ Login   ← No vulnerability needed;
  succeeded  succeeded    only reuse needed

Phishing exploits a different structural weakness: in the password model, authentication requires the user to transmit the secret to the server. A user cannot reliably tell the difference between typing their password into bank.com and typing it into bank-secure-login.com. The URL might look slightly off, but the act of typing and pressing enter is identical. Studies of phishing effectiveness consistently find that even security-aware users are deceived under the right conditions — time pressure, visual similarity, social context. The model asks humans to detect subtle deception at scale and under adversarial conditions. That is a structural mismatch between the threat and the defense, not a training problem.

Breach exposure is the server-side failure mode. In a password-based system, the server must store something that allows it to verify the user's password. Best practice is to store a slow hash (bcrypt, scrypt, Argon2) rather than the password directly, but the hash is still a transformed version of the secret — a determined attacker with enough compute can crack weak and medium-strength passwords from their hashes. The server holds a secret for every user, making it a permanently high-value target.

⚠️ Common Mistake: Equating "we use bcrypt" with "we've solved the breach exposure problem." Bcrypt significantly raises the cost of cracking strong passwords, but it does not eliminate exposure — it makes exploitation slower for some fraction of the credential database. Weak or moderate-strength passwords remain practically crackable even with good hashing.

🤔 Did you know? The three failure modes map onto three different attacker capabilities: credential stuffing requires scale (a breach elsewhere), phishing requires deception (a fake site), and breach exposure requires access (a database compromise). Addressing only one leaves two attack surfaces open.

The Passkey Model: Eliminating the Shared Secret

Passkeys address all three failure modes simultaneously — not by adding mitigations on top of the password model, but by discarding the shared-secret model entirely.

A passkey is a public-key credential. When a user registers a passkey with a site, the authenticator (a secure chip in their device, a hardware key, or a platform credential manager) generates an asymmetric key pair. The private key is generated inside the authenticator and never exported. The public key is sent to the server and stored there. That is all the server ever receives.

Passkey Registration
┌─────────────────────────────────────────────┐
│  Authenticator (device)                     │
│  ┌──────────────┐                           │
│  │ Private Key  │  ← Never leaves here      │
│  └──────────────┘                           │
│         │ generates key pair                │
│  ┌──────────────┐                           │
│  │  Public Key  │ ──────────────────────►  Server
│  └──────────────┘                           │  stores public key only
└─────────────────────────────────────────────┘

Trace through the three failure modes with this model:

🔒 Against credential stuffing: There is no password to stuff. Each passkey is scoped to a specific site (a relying party ID, which is a domain string). A passkey registered on example.com cannot be used on any other site — even if an attacker had access to the device's credential store, they could not export the private key to use elsewhere.

🔒 Against phishing: The authenticator, not the user, decides what origin a credential is valid for. When the browser calls into the authenticator to sign a challenge, it includes the requesting origin in the signed data. If the user is on evil-example.com instead of example.com, the authenticator has no credential for that origin, and authentication cannot proceed. The user does not need to detect the deception — the protocol makes deception structurally ineffective.

🔒 Against breach exposure: The server stores a public key. A public key is meant to be public. If an attacker breaches the server and steals the public key database, they have gained nothing useful — you cannot authenticate as a user by knowing only their public key. There is no secret on the server to steal.

🎯 Key Principle: The passkey security model replaces a shared secret (something both the user and server know) with asymmetric proof (the user proves possession of a private key without revealing it). The server never has a secret worth stealing.

📋 Quick Reference Card: Passwords vs. Passkeys

🔑 Passwords 🔒 Passkeys
🗄️ Server stores Password hash (secret-derived) Public key (not secret)
🎭 Phishing risk High — user can be deceived Structural defense — origin-bound
♻️ Reuse risk High — user behavior N/A — credential is site-scoped
💥 Breach impact Hashes exposed; cracking possible Public keys exposed; no secret value
👤 User experience Type and remember password Biometric/PIN confirmation
🏗️ Architecture Shared secret Asymmetric proof

WebAuthn: The Standard That Makes Passkeys Portable

If passkeys are public-key credentials, WebAuthn (Web Authentication) is the standardized API through which websites and browsers agree on how to create and use them. Without a standard, every platform would implement public-key authentication differently — Apple's approach incompatible with Google's, which would be incompatible with Microsoft's — and developers would face a fragmented ecosystem harder to integrate than the passwords it was meant to replace.

WebAuthn is a W3C standard, developed in collaboration with the FIDO Alliance as part of the FIDO2 project. It specifies the JavaScript API the browser exposes (navigator.credentials.create() for registration, navigator.credentials.get() for authentication), the data structures exchanged during each ceremony, and the validation logic the server (called the relying party) must perform to verify responses.

A passkey, in precise terms, is a credential that conforms to the WebAuthn standard. The term "passkey" has become the consumer-facing name for synced WebAuthn credentials — credentials backed up and synchronized across a user's devices via a platform credential manager. But whether a credential is device-bound or synced, whether it lives on a hardware security key or in a platform keychain, it is a WebAuthn credential.

  WebAuthn Ecosystem (simplified)

  ┌────────────────┐        ┌────────────────┐
  │  Authenticator │◄──────►│    Browser     │
  │  (device/key)  │        │  (WebAuthn API)│
  └────────────────┘        └───────┬────────┘
                                    │
                          HTTPS     │  Credentials
                                    │  Challenges
                            ┌───────▼────────┐
                            │  Relying Party │
                            │  (your server) │
                            └────────────────┘

  W3C WebAuthn standard defines the interfaces
  at ALL of these boundaries.

Why This Is Structural, Not Incremental

It is tempting to categorize passkeys alongside other authentication improvements — stronger password requirements, multi-factor authentication, password managers — as incremental hardening of the existing model. They are not.

MFA via TOTP or SMS does not eliminate phishing (an attacker can ask for both the password and the code in real time — so-called real-time phishing or MFA fatigue attacks), does not solve credential stuffing (the password is still reused), and does not remove the server-side secret (TOTP seeds or backup codes are server-stored secrets that can be breached). MFA improves security posture significantly, but it patches the password model rather than replacing it.

Passkeys replace the model. That replacement is now happening at infrastructure scale:

  • Major operating systems ship built-in passkey support — platform authenticators are available across major ecosystems without additional software.
  • Major browsers expose the navigator.credentials API by default, so any web application can call WebAuthn without polyfills or third-party libraries.
  • Platform credential managers synchronize passkeys across devices within an ecosystem, solving the "I only registered on one device" usability problem that plagued earlier FIDO deployments.
  • Password managers from independent vendors have added passkey support, extending the model to cross-platform and cross-ecosystem scenarios.

The practical implication: passkey support is no longer an advanced feature requiring special hardware. For a large fraction of users, the infrastructure already exists on their device. The question for application developers is no longer "should we evaluate this?" but "how do we integrate and roll this out?"

⚠️ Common Mistake: Assuming that passkey adoption requires users to understand public-key cryptography or take deliberate setup steps. From the user's perspective, registering a passkey often involves nothing more than confirming with Face ID, Touch ID, or a device PIN — the same gesture they use to unlock their phone. The cryptographic complexity is entirely hidden by the platform.

With that motivation established, the next section examines the exact cryptographic mechanics that make these guarantees possible.


The Cryptographic Foundation: Public-Key Credentials and Attestation

Every passkey interaction reduces, at its core, to a single cryptographic idea: a secret that proves identity without ever being transmitted. Understanding this mechanism precisely — not just intuitively — is what separates developers who can debug a failing WebAuthn implementation from those who are guessing.

Asymmetric Key Pairs and the Private Key That Never Travels

A passkey is an asymmetric key pair — two mathematically linked keys generated together, where data signed by one key can be verified by the other. The private key is held secret inside the authenticator; the public key can be shared freely without compromising security.

The authenticator — whether a platform chip, a hardware security key, or a synced credential in a platform credential manager — generates this key pair and holds the private key in a protected environment (a Secure Enclave, Trusted Execution Environment, or equivalent). The private key is never exported and never transmitted. The relying party receives and stores only the public key during registration.

Each key pair is also scoped to a specific relying party ID. The RP ID is a domain string — for example, example.com — that the server declares when requesting a credential. The authenticator ties the key pair to that RP ID at creation time. The same authenticator can hold many key pairs, each scoped to a different origin, and they are not interchangeable.

AUTHENTICATOR                          RELYING PARTY SERVER
+-------------------------+            +-------------------------+
|                         |            |                         |
|  [Private Key] ←keeps   |            |  Stores: Public Key     |
|                         |  ────────► |           Credential ID |
|  Generates key pair     | public key |                         |
|  Scoped to RP ID        |            |  Scoped to RP ID:       |
|                         |            |  example.com            |
+-------------------------+            +-------------------------+

      Private key never crosses this boundary →

How Authentication Works: Signing a Challenge

Once a credential is registered, authentication follows a challenge-response protocol. The relying party generates a cryptographic challenge — a large, random, single-use value — and sends it to the client. The authenticator signs this challenge using the private key. The server verifies the signature against the stored public key.

Signature verification is asymmetric in intent: anyone holding the public key can verify that a signature was produced by the corresponding private key, but cannot produce a valid signature themselves. The private key is never involved in verification.

AUTHENTICATION FLOW

Server                    Browser/Client               Authenticator
  |                            |                             |
  |--- challenge (random) ---->|                             |
  |                            |--- request to sign -------->|
  |                            |                             | [user gesture]
  |                            |                             | signs: challenge +
  |                            |                             | clientDataJSON +
  |                            |                             | authenticatorData
  |                            |<-- signed assertion ---------|
  |<-- assertion response -----|                             |
  |                            |                             |
  | verifies signature         |                             |
  | using stored public key    |                             |
  | (private key never seen)   |                             |

💡 Mental Model: Think of the private key as a stamp that can imprint a unique mark, and the public key as a stencil that verifies whether the mark was made by that specific stamp. The server holds the stencil; only the authenticator holds the stamp. A verified imprint proves the stamp was used — the stamp itself never needed to be handed over.

The Credential ID: An Opaque Handle, Not a Secret

When an authenticator generates a key pair, it also produces a credential ID — an opaque byte string that serves as a lookup handle. The relying party stores this ID alongside the public key. When an authentication request is initiated, the server can include a list of allowed credential IDs so the authenticator knows which key pair to use.

The credential ID carries no secret information. Knowing a credential ID gives an attacker nothing useful — it does not reveal the private key, allow constructing a valid signature, or expose user data. Its only function is to let the server locate the correct public key for verification from among potentially many credentials stored per user.

The credential ID also has a practical implication for your data model: a single user account must store multiple credential IDs and their corresponding public keys — one set per registered authenticator. A user might register a passkey on their phone, a hardware security key, and their laptop. Each produces a distinct key pair with a distinct credential ID. This one-to-many relationship is covered further in the common mistakes section.

Origin Binding: Phishing Resistance Built Into the Signature

One of the most consequential security properties of WebAuthn is one that is easy to underappreciate: credentials are origin-bound, and this binding is cryptographic, not advisory.

When the browser handles a WebAuthn operation, it constructs a clientDataJSON object that includes the origin of the page making the request — the scheme, host, and port, such as https://example.com. This object is included in the data that the authenticator signs. The relying party verifies that the origin embedded in the signed clientDataJSON matches what it expects.

CLIENT DATA JSON (included in signed payload)
{
  "type": "webauthn.get",
  "challenge": "<base64url-encoded challenge>",
  "origin": "https://example.com",     <-- browser fills this in
  "crossOrigin": false
}

Because this field is inside the signed payload, it cannot be tampered with after signing without invalidating the signature. Consider a phishing attack: a user navigates to https://evil-example.com, which looks identical to https://example.com. The attacker's page calls the WebAuthn API. The browser fills in the origin as https://evil-example.com. The authenticator signs this. When the attacker attempts to replay the assertion against the real https://example.com server, the server checks the origin in the signed data — https://evil-example.com — and rejects it.

PHISHING ATTEMPT — WHY IT FAILS

User registers passkey on:
  Origin:  https://example.com
  RP ID:   example.com

Attacker lures user to:
  https://evil-example.com  (looks identical)

WebAuthn API called from evil-example.com:
  Browser fills origin: "https://evil-example.com"
  Authenticator signs this data

Attacker sends assertion to real example.com server:
  Server checks origin in signed payload:
  "https://evil-example.com" ≠ "https://example.com"
                                         ^
                               VERIFICATION FAILS — replay rejected

🎯 Key Principle: Phishing resistance is not a UI feature in WebAuthn — it is a cryptographic guarantee. The credential is structurally unusable on any origin other than the one it was registered for. This is categorically different from TOTP codes or SMS OTPs, where a phishing site can relay the code to the real site in real time.

🤔 Did you know? The RP ID and the origin are related but distinct. The RP ID is a domain suffix (e.g., example.com), and an origin is a full scheme-host-port triple (e.g., https://login.example.com). A credential's RP ID must be a registrable domain suffix of the origin. A credential registered with RP ID example.com can be used from https://login.example.com or https://www.example.com, but not from https://evil-example.com or https://example.net.

Attestation: What It Is, What It Proves, and When It Matters

Attestation is a mechanism by which an authenticator can cryptographically prove its own identity — specifically, its make, model, and security properties — to the relying party at registration time. It is entirely separate from the authentication assertion and operates only during credential creation.

Most authenticators are manufactured with an attestation key pair embedded at the factory. When a new passkey is created, the authenticator signs the new credential's public key with its attestation private key, producing an attestation statement. The relying party can verify this statement against a known root certificate from the authenticator vendor, confirming that the credential was generated by a genuine device of the claimed type.

ATTESTATION STRUCTURE AT REGISTRATION

 Authenticator
 +-----------------------------------------------+
 |  New key pair generated:                      |
 |    credentialPublicKey (what server stores)   |
 |    credentialPrivateKey (stays here)          |
 |                                               |
 |  Attestation statement:                       |
 |    sign(credentialPublicKey, attestationKey)  |
 |    ← signed by factory-embedded key           |
 +-----------------------------------------------+
          |
          | attestation statement + cert chain
          v
 Relying Party Server
 +-----------------------------------------------+
 |  Verify attestation cert chain against        |
 |  known root (e.g., FIDO Metadata Service)     |
 |                                               |
 |  Confirms: this public key was generated      |
 |  by a genuine Authenticator Model X           |
 +-----------------------------------------------+

Attestation answers: "Can I trust the hardware that generated this key pair?" It does not authenticate the user; that is the assertion's job.

For the majority of consumer-facing applications, attestation is unnecessary complexity. The authentication guarantee — that the private key is held by whoever registered the credential, and that they must perform the challenge-response successfully — holds regardless of whether attestation was verified. Attestation adds a policy layer on top of this guarantee, not a replacement for it.

Where attestation becomes meaningful is in high-assurance enterprise or regulated environments. A government system requiring hardware-backed credentials from a specific device class, or an enterprise wanting to ensure employees only register credentials from managed corporate devices, can use attestation to enforce these constraints at enrollment time. The FIDO Metadata Service (MDS) provides a registry of authenticator models with their attestation root certificates and security properties.

⚠️ Common Mistake: Assuming that skipping attestation validation weakens authentication security. It does not. Attestation is about device policy, not the validity of the cryptographic binding between key pair and user. If you do not have a business requirement to constrain which authenticator types are acceptable, skipping attestation validation is the correct default.

The Four Properties That Compose the Security Model

The cryptographic foundation of WebAuthn has four interlocking properties:

Property Mechanism Security Consequence
🔒 Private key isolation Key generated and held in authenticator hardware Server breach exposes no usable secret
🔒 Challenge signing Authenticator signs server-issued random nonce Replay attacks fail; each assertion is unique
🔒 Origin binding Browser embeds origin in signed payload Credential unusable on any other origin; phishing fails cryptographically
🔒 Attestation (optional) Authenticator signs new credential with factory key Relying party can verify authenticator model and enforce device policy

These properties compose: origin binding stops credential replay across origins; challenge signing stops replay within the same origin; private key isolation stops an attacker who has compromised the server from impersonating users; attestation — when used — stops enrollment of weak authenticators in policy-controlled environments.

🧠 Mnemonic: Think ICOA — Isolation, Challenge, Origin, Attestation. The first three form the baseline security guarantee present in every WebAuthn deployment. Attestation is the optional fourth layer for environments with device-level trust requirements.

With this cryptographic foundation established, the natural next question is: who are the actors in a WebAuthn deployment, what data structures carry these cryptographic objects between them, and how do platform versus roaming authenticators differ in their threat models?


The WebAuthn Ecosystem: Actors, Roles, and Data Structures

Before you can read a WebAuthn registration response or debug a failed assertion, you need a clear map of who is doing what and which data is flowing where. This section builds that map: three actors, two authenticator categories, a passkey distinction that matters for real deployments, four data structures you will encounter constantly, and one configuration decision you cannot reverse later.

The Three Actors and Their Responsibility Boundaries

Every WebAuthn operation involves exactly three participants. Understanding what each one owns — and what it explicitly does not own — is the foundation for reading the spec, debugging real failures, and making correct implementation decisions.

┌─────────────────────────────────────────────────────────────────┐
│                    WebAuthn Operation Flow                       │
│                                                                  │
│  ┌─────────────────┐    ┌──────────────────┐    ┌────────────┐  │
│  │  Relying Party  │◄──►│  Client          │◄──►│Authenticator│ │
│  │  (Your Server)  │    │  (Browser)       │    │(Device/Key) │ │
│  │                 │    │                  │    │             │ │
│  │ • Generates     │    │ • Mediates       │    │ • Holds     │ │
│  │   challenge     │    │   between RP     │    │   private   │ │
│  │ • Stores pub key│    │   and authn      │    │   key       │ │
│  │ • Verifies sig  │    │ • Calls WebAuthn │    │ • Signs     │ │
│  │ • Defines policy│    │   API            │    │   challenge │ │
│  │                 │    │ • Origin binding │    │ • User      │ │
│  │                 │    │   enforcement    │    │   verify    │ │
│  └─────────────────┘    └──────────────────┘    └────────────┘  │
│         ▲                       ▲                               │
│         │    JSON over HTTPS    │    CTAP2 / Platform API       │
│         └───────────────────────┘                               │
└─────────────────────────────────────────────────────────────────┘

The authenticator holds the private key and performs cryptographic operations. It also interacts directly with the user — confirming presence, running a biometric check, or prompting for a PIN. The private key material never leaves the authenticator boundary.

The client (the browser) mediates: it receives instructions from the relying party, translates them into the appropriate authenticator communication protocol, and hands results back to the server. One of the client's most important security contributions is origin binding — the browser encodes the origin of the requesting page into the data the authenticator signs. The client enforces this automatically; the relying party verifies it.

The relying party (your server) initiates both ceremonies by producing a challenge and options, and concludes both by verifying the authenticator's response. It stores the user's public key and credential ID — not a password, not a secret — and defines policy: which authenticator types are acceptable, whether user verification is required, which credentials are allowed for a given user.

🎯 Key Principle: The responsibility split is clean by design. The authenticator proves; the client mediates; the relying party verifies. When a WebAuthn implementation has a security gap, it is almost always because one actor took on a responsibility that belonged to another — for example, a server that trusts the client's claim about the origin rather than checking it independently.

Platform vs. Roaming Authenticators

The WebAuthn specification divides authenticators into two categories based on how they attach to the client device.

Platform authenticators are built into the device. Face ID and Touch ID on Apple devices, Windows Hello on PCs, and biometric sensors on Android devices are all platform authenticators. They communicate with the browser through OS APIs rather than an external transport. User verification (biometric or PIN) is a natural part of the interaction.

Roaming authenticators are external devices that attach over USB, NFC, or BLE. FIDO2 security keys are the canonical example; they communicate using the CTAP2 protocol. Roaming authenticators are portable across devices — the same security key works on a desktop in the office and a laptop at home — but they are physical objects that can be lost, forgotten, or stolen.

Authenticator Taxonomy

├── Platform Authenticator
│   ├── Face ID / Touch ID (Apple)
│   ├── Windows Hello (biometric or PIN)
│   └── Android biometric / screen-lock
│   Transport: OS platform API (internal)
│
└── Roaming Authenticator
    ├── FIDO2 security keys (USB-A, USB-C)
    ├── NFC-capable security tokens
    └── BLE-capable security keys
    Transport: CTAP2 over USB / NFC / BLE

💡 Real-World Example: An enterprise deploying passkeys for employees working across shared workstations might issue roaming authenticators (security keys) so the credential follows the person, not the machine. A consumer application serving mobile users would lean toward platform authenticators for friction-free biometric login. Both are valid WebAuthn deployments; the choice is a deployment decision, not a protocol decision.

Synchronizable Passkeys vs. Device-Bound Passkeys

Within the category of platform authenticators, a further distinction has become operationally significant: whether the credential is synchronizable or device-bound.

A synchronizable passkey is a WebAuthn credential whose private key material is backed up and synced across devices through a credential manager. iCloud Keychain syncs passkeys across Apple devices; Google Password Manager syncs them across Android and Chrome. If a user creates a passkey on their iPhone and then authenticates on a new iPad without any additional registration step, that is a synced passkey working as designed.

A device-bound passkey is a credential whose private key is generated inside the authenticator and explicitly designed never to leave it. Hardware security keys are device-bound by construction.

Property Synchronizable Passkey Device-Bound Passkey
🔒 Private key leaves device? Yes (encrypted, to credential manager) Never
📱 Available on new device? Yes (via sync, after account sign-in) No (physical key required)
🔄 Recoverability High (tied to platform account recovery) Low (device or key loss = credential loss)
🛡️ Threat model concern Credential manager account compromise Physical device loss
🏢 Typical use case Consumer apps, employee productivity High-assurance, regulated environments

Neither option is universally superior. Synchronizable passkeys dramatically improve recoverability but tie the security of the credential to the security of the platform account. Device-bound passkeys offer stronger guarantees about where the private key can ever be, which matters in environments where that assurance is required by policy or regulation.

⚠️ Common Mistake: Assuming that "passkey" always means "synced passkey." The WebAuthn specification does not distinguish between synced and device-bound credentials at the protocol level — both produce valid AuthenticatorAttestationResponse and AuthenticatorAssertionResponse objects. The distinction lives in authenticator behavior, not in the API surface you interact with.

The Four Core Data Structures

WebAuthn's API surface has exactly two operations — create (registration) and get (authentication) — and each operation involves one options object flowing from server to client, and one response object flowing from client back to server.

Data Flow: Registration Ceremony

Server                    Browser                   Authenticator
  │                          │                           │
  │ PublicKeyCredential       │                           │
  │ CreationOptions ─────────►│                           │
  │                          │────── create request ─────►│
  │                          │                           │ (key gen + sign)
  │                          │◄───── attestation ─────────│
  │◄─ AuthenticatorAttestation│                           │
  │   Response ───────────────│                           │

Data Flow: Authentication Ceremony

Server                    Browser                   Authenticator
  │                          │                           │
  │ PublicKeyCredential       │                           │
  │ RequestOptions ──────────►│                           │
  │                          │────── get request ────────►│
  │                          │                           │ (sign challenge)
  │                          │◄───── assertion ───────────│
  │◄─ AuthenticatorAssertion  │                           │
  │   Response ───────────────│                           │

PublicKeyCredentialCreationOptions — the object your server constructs and sends to the browser at the start of a registration ceremony. Key fields:

  • rp — relying party descriptor containing the RP ID and a human-readable name
  • user — user account descriptor with a user handle, display name, and name
  • challenge — server-generated random byte sequence
  • pubKeyCredParams — ordered list of acceptable public-key algorithm and key-type combinations (e.g., ES256, RS256)
  • authenticatorSelection — optional policy hints for authenticator attachment, discoverability, and user verification
  • excludeCredentials — list of credential IDs already registered for this user
  • timeout — how long the browser should wait for the user

PublicKeyCredentialRequestOptions — the counterpart for authentication. Simpler, because the key pair already exists:

  • challenge — a fresh server-generated random byte sequence
  • rpId — the relying party ID the credentials were registered under
  • allowCredentials — optional list of previously registered credential IDs; if empty, the authenticator performs a discoverable credential lookup
  • userVerification — policy for whether the authenticator must verify the user's identity
  • timeout

AuthenticatorAttestationResponse — returned by navigator.credentials.create(). Contains:

  • clientDataJSON — JSON with challenge, origin, and operation type (webauthn.create)
  • attestationObject — CBOR-encoded structure containing authenticator data (new public key, credential ID, UP/UV flags) plus any attestation statement

AuthenticatorAssertionResponse — returned by navigator.credentials.get(). Contains:

  • clientDataJSON — same structure with operation type webauthn.get
  • authenticatorData — signed data including RP ID hash, flags, and signature counter
  • signature — the authenticator's signature over authenticator data concatenated with the hash of the client data JSON
  • userHandle — user handle associated with the credential (present for discoverable credentials)

💡 Mental Model: Think of the two options objects as the server's instructions to the ceremony — here is what I want, and here is a fresh nonce to prove the response is live. Think of the two response objects as the ceremony's signed receipts — here is the result, and here is cryptographic proof it happened for this specific request, at this specific origin.

📋 Quick Reference Card:

Object Direction Ceremony Key Contents
🔧 PublicKeyCredentialCreationOptions Server → Browser Registration RP ID, challenge, user, pubKeyCredParams
🔧 PublicKeyCredentialRequestOptions Server → Browser Authentication RP ID, challenge, allowCredentials
📬 AuthenticatorAttestationResponse Browser → Server Registration clientDataJSON, attestationObject (pubkey + credential ID)
📬 AuthenticatorAssertionResponse Browser → Server Authentication clientDataJSON, authenticatorData, signature

The Relying Party ID: The Decision You Cannot Undo

The relying party ID scopes every credential created under a WebAuthn deployment. Every credential stores a hash of the RP ID at creation time, and every authentication assertion includes that same hash. The server verifies that the hash in the assertion matches the RP ID it expects.

The RP ID must be a registrable domain suffix of the effective domain of the page making the WebAuthn call:

RP ID Scoping Rules

  Registration origin: https://login.example.com

  Valid RP IDs:
    ✅ login.example.com   (exact match)
    ✅ example.com         (registrable suffix)

  Invalid RP IDs:
    ❌ auth.example.com    (different subdomain)
    ❌ example.net         (different TLD)
    ❌ com                 (public suffix — not allowed)

  Cross-subdomain access with RP ID = "example.com":
    ✅ login.example.com can create credentials
    ✅ app.example.com can authenticate them
    ✅ account.example.com can authenticate them

⚠️ Common Mistake: Setting the RP ID to a specific subdomain during initial development (e.g., auth.example.com) and then needing to move to a different subdomain later. WebAuthn provides no credential migration mechanism — there is no API to transfer a credential from one RP ID to another. For most consumer deployments, setting the RP ID to the apex domain (e.g., example.com) from the start preserves future flexibility across subdomains.

💡 Pro Tip: If you are building a multi-tenant SaaS product where each tenant has its own subdomain (tenant1.example.com, tenant2.example.com), an RP ID of example.com means your server-side logic must enforce tenant isolation — the RP ID alone will not do it. An RP ID per tenant subdomain enforces harder isolation at the credential level but complicates centralized authentication flows. Think through this before you begin rollout.

🧠 Mnemonic: Think of the RP ID as a postal code for credentials — it determines which addresses (origins) a credential can be delivered to. You choose the postal code at registration time, and reshipping to a different postal code later is not supported.

With this map of actors, authenticator types, data structures, and RP ID in place, the next section traces the full flow of both ceremonies end-to-end, grounding these structures in observable browser behavior and concrete server-side logic.


Passkeys in Practice: A Registration and Authentication Walkthrough

Understanding passkeys conceptually is one thing; watching them work end-to-end is another. This section walks through both WebAuthn ceremonies as concrete sequences of events with real code, observable browser behavior, and explicit server-side logic. The goal is to make the abstract data structures and cryptographic handshakes from the previous sections visible as actual HTTP exchanges and JavaScript calls.

The Registration Ceremony: Creating a Passkey

Registration binds a key pair to a specific user account and a specific origin. The sequence has four distinct phases.

Phase 1: Server Generates and Sends Creation Options

Everything starts on the server. Before the browser can do anything, your backend must generate a challenge — a cryptographically random byte sequence that will be signed during the ceremony and verified afterward. The challenge's job is to prevent replay attacks: an attacker who intercepts a valid registration response cannot reuse it because the challenge is single-use and expires.

// Server-side (Node.js example — adapt to your stack)
const crypto = require('crypto');

function generateRegistrationOptions(user) {
  const challenge = crypto.randomBytes(32); // 32 bytes = 256 bits of entropy

  // Store challenge in server-side session with a short TTL
  session.set('pending_challenge', {
    value: challenge.toString('base64url'),
    expiresAt: Date.now() + 5 * 60 * 1000 // 5-minute window
  });

  return {
    challenge: challenge.toString('base64url'),
    rp: {
      name: 'Acme Corp',
      id: 'acme.example.com'
    },
    user: {
      id: Buffer.from(user.id).toString('base64url'),
      name: user.email,
      displayName: user.fullName
    },
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 },  // ES256 (ECDSA with P-256)
      { type: 'public-key', alg: -257 } // RS256 (RSASSA-PKCS1-v1_5)
    ],
    authenticatorSelection: {
      userVerification: 'preferred'
    },
    timeout: 300000
  };
}

Two details deserve attention. First, the challenge is stored server-side in the session — it is not derived from user data or any predictable value. Second, pubKeyCredParams lists acceptable signing algorithms in preference order; listing ES256 first signals a preference for elliptic-curve keys, which are smaller and faster to verify than RSA keys.

Phase 2: Browser Calls navigator.credentials.create()

The client-side code is deliberately thin. The browser receives the options, converts base64url-encoded byte arrays back into ArrayBuffer values, and passes everything to the WebAuthn API:

// Client-side JavaScript
async function startRegistration(optionsFromServer) {
  const publicKey = {
    ...optionsFromServer,
    challenge: base64urlDecode(optionsFromServer.challenge),
    user: {
      ...optionsFromServer.user,
      id: base64urlDecode(optionsFromServer.user.id)
    }
  };

  const credential = await navigator.credentials.create({ publicKey });
  return credential;
}

At this point, the browser and OS take over. A system dialog appears — on macOS it might show Touch ID, on Windows it shows Windows Hello, on a phone it surfaces Face ID or fingerprint unlock. The developer has no control over this UI, which is intentional: the OS-native dialog makes it significantly harder for a malicious page to fake the prompt.

If the authenticator is a synced platform authenticator (iCloud Keychain, Google Password Manager), the key pair may be generated on-device and then synchronized to the user's other devices. If it is a device-bound authenticator or a roaming security key, the private key stays on that specific authenticator. From the API's perspective the call looks identical.

Phase 3: Authenticator Returns a Credential

The resolved credential object is an AuthenticatorAttestationResponse. It contains several pieces of data the server must parse:

AuthenticatorAttestationResponse
├── clientDataJSON       ← JSON blob with origin, challenge, type
├── attestationObject   ← CBOR-encoded blob containing:
│   ├── authData        ← authenticator data (flags, counter, key)
│   │   ├── rpIdHash    ← SHA-256 of the RP ID
│   │   ├── flags       ← UP flag, UV flag, AT flag (attestation present)
│   │   ├── signCount   ← initial signature counter (often 0 at registration)
│   │   └── attestedCredentialData
│   │       ├── aaguid          ← authenticator model identifier
│   │       ├── credentialId    ← the opaque credential ID
│   │       └── credentialPublicKey ← COSE-encoded public key
│   ├── fmt             ← attestation format
│   └── attStmt         ← attestation statement (may be empty for 'none')

The clientDataJSON includes the challenge the server sent, which is how the server confirms the response is fresh and corresponds to this session. The credentialPublicKey inside authData is what the server will store permanently.

Phase 4: Server Verifies and Stores
// Server-side verification (simplified — use a vetted library in production)
async function verifyRegistration(credential, session) {
  const { clientDataJSON, attestationObject } = credential.response;

  // 1. Decode and parse clientDataJSON
  const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString());

  // 2. Verify type
  if (clientData.type !== 'webauthn.create') throw new Error('Wrong type');

  // 3. Verify the challenge matches what we stored in the session
  const storedChallenge = session.get('pending_challenge');
  if (clientData.challenge !== storedChallenge.value) throw new Error('Challenge mismatch');
  if (Date.now() > storedChallenge.expiresAt) throw new Error('Challenge expired');

  // 4. Verify the origin matches our expected origin
  if (clientData.origin !== 'https://acme.example.com') throw new Error('Origin mismatch');

  // 5. Parse the attestation object (CBOR decoding required)
  const { authData } = parseCBOR(attestationObject);

  // 6. Verify the RP ID hash
  const expectedRpIdHash = crypto.createHash('sha256').update('acme.example.com').digest();
  if (!authData.rpIdHash.equals(expectedRpIdHash)) throw new Error('RP ID hash mismatch');

  // 7. Confirm user presence flag is set
  if (!authData.flags.userPresent) throw new Error('User presence required');

  // 8. Store the credential for future authentication
  await db.credentials.create({
    userId: session.userId,
    credentialId: authData.credentialId,
    publicKey: authData.credentialPublicKey,
    signCount: authData.signCount,
    aaguid: authData.aaguid
  });

  session.delete('pending_challenge');
}

⚠️ Common Mistake: In production you should use a well-tested WebAuthn server library (such as @simplewebauthn/server for Node.js, py_webauthn for Python, or your platform's equivalent) rather than hand-rolling CBOR parsing and flag verification. The snippet above illustrates the logic you need to understand; it simplifies some steps and omits attestation verification entirely.

🎯 Key Principle: The server's job in registration is to confirm three things: the challenge is the one it issued, the origin is the one it controls, and the RP ID hash matches. If all three pass, it can trust that the public key came from a legitimate ceremony on the correct site.

The Authentication Ceremony: Using a Passkey

Authentication is structurally simpler than registration because no new key material is created. The server issues a fresh challenge, the authenticator signs it with the existing private key, and the server verifies that signature against the stored public key.

REGISTRATION vs AUTHENTICATION — comparison

  REGISTRATION                     AUTHENTICATION
  ─────────────────────────────    ──────────────────────────────────
  Server sends:  challenge +       Server sends:  challenge +
                 creation options               request options

  Authenticator: generates key     Authenticator: signs challenge
                 pair                            with existing private key

  Server stores: public key +      Server verifies: signature using
                 credential ID +                    stored public key
                 signCount

  Net result:    credential        Net result:    session established
                 registered
Server Sends Request Options
function generateAuthenticationOptions(user) {
  const challenge = crypto.randomBytes(32);

  session.set('pending_challenge', {
    value: challenge.toString('base64url'),
    expiresAt: Date.now() + 5 * 60 * 1000
  });

  const userCredentials = await db.credentials.findByUserId(user.id);

  return {
    challenge: challenge.toString('base64url'),
    rpId: 'acme.example.com',
    allowCredentials: userCredentials.map(cred => ({
      type: 'public-key',
      id: cred.credentialId,
      transports: cred.transports
    })),
    userVerification: 'preferred',
    timeout: 300000
  };
}

The allowCredentials list is optional. If omitted, the browser shows all passkeys available for the site's RP ID — a discoverable credential flow, and how passwordless login with no username prompt works. If provided, the browser narrows down to the listed credential IDs, useful when a user has already identified themselves.

Browser Calls navigator.credentials.get()
async function startAuthentication(optionsFromServer) {
  const publicKey = {
    ...optionsFromServer,
    challenge: base64urlDecode(optionsFromServer.challenge),
    allowCredentials: optionsFromServer.allowCredentials?.map(cred => ({
      ...cred,
      id: base64urlDecode(cred.id)
    }))
  };

  const assertion = await navigator.credentials.get({ publicKey });
  return assertion;
}

The OS presents a prompt, the user verifies via biometric or PIN, and the authenticator signs the challenge using its private key. The result is an AuthenticatorAssertionResponse.

Server Verifies the Assertion
async function verifyAuthentication(assertion, session) {
  const { clientDataJSON, authenticatorData, signature } = assertion.response;

  // 1. Parse and verify clientDataJSON
  const clientData = JSON.parse(Buffer.from(clientDataJSON, 'base64url').toString());
  if (clientData.type !== 'webauthn.get') throw new Error('Wrong type');

  const storedChallenge = session.get('pending_challenge');
  if (clientData.challenge !== storedChallenge.value) throw new Error('Challenge mismatch');
  if (Date.now() > storedChallenge.expiresAt) throw new Error('Challenge expired');
  if (clientData.origin !== 'https://acme.example.com') throw new Error('Origin mismatch');

  // 2. Parse authenticatorData and verify RP ID hash + flags
  const authData = parseAuthenticatorData(authenticatorData);
  const expectedRpIdHash = crypto.createHash('sha256').update('acme.example.com').digest();
  if (!authData.rpIdHash.equals(expectedRpIdHash)) throw new Error('RP ID mismatch');
  if (!authData.flags.userPresent) throw new Error('User presence required');

  // 3. Look up the stored credential
  const storedCredential = await db.credentials.findByCredentialId(assertion.id);
  if (!storedCredential) throw new Error('Unknown credential');

  // 4. Reconstruct the signed data: authenticatorData || SHA-256(clientDataJSON)
  const clientDataHash = crypto.createHash('sha256')
    .update(Buffer.from(clientDataJSON, 'base64url'))
    .digest();
  const signedData = Buffer.concat([
    Buffer.from(authenticatorData, 'base64url'),
    clientDataHash
  ]);

  // 5. Verify the signature against the stored public key
  const isValid = verifySignature({
    publicKey: storedCredential.publicKey,
    signature: Buffer.from(signature, 'base64url'),
    data: signedData
  });
  if (!isValid) throw new Error('Signature verification failed');

  // 6. Check and update the signature counter
  if (authData.signCount > 0 || storedCredential.signCount > 0) {
    if (authData.signCount <= storedCredential.signCount) {
      throw new Error('Signature counter anomaly detected');
    }
  }
  await db.credentials.updateSignCount(storedCredential.id, authData.signCount);

  session.delete('pending_challenge');
  return { userId: storedCredential.userId };
}

The signature verification in step 5 is the cryptographic core of the entire protocol. The server asks: can the private key corresponding to the public key I stored sign this exact challenge? If yes, and all other checks pass, authentication succeeds.

The Signature Counter: Cloning Detection

The signature counter is a monotonically increasing integer that the authenticator increments each time it produces an assertion. The server stores the last-seen counter value, and on each authentication it checks that the new counter is strictly greater than the stored one.

If an attacker extracts the private key from an authenticator and creates a clone, both the legitimate authenticator and the clone share the same starting counter. When one increments ahead of the other, the server will eventually see a counter value that is not greater than the last seen from the other copy, triggering an anomaly.

  Authenticator A (legitimate)
  signCount: 10  →  11  →  12  →  13

  Attacker's clone of A
  signCount: (cloned at count 10)  →  11 ← SERVER SEES THIS IS ≤ 13
                                              and raises a counter anomaly

This property is useful for device-bound credentials where the counter reliably increments. However, synced passkeys complicate the picture significantly. When a private key is synchronized across multiple devices, each device may maintain its own counter or the platform may keep a shared counter. In practice, many synced passkey implementations set the counter to zero and never increment it, or increment in ways that are not strictly monotonic across devices. The WebAuthn specification explicitly accounts for this: if both the stored counter and the asserted counter are zero, the server should skip the counter check rather than falsely flagging every authentication.

⚠️ Common Mistake: Treating a counter anomaly as definitive proof of a cloned credential and immediately locking the account. The appropriate response is typically to flag the event for review and possibly challenge the user for additional verification — especially if the account uses synced passkeys, where counter anomalies can be benign.

User Verification vs. User Presence

The authenticator data flags encode two distinct signals that are frequently conflated.

User Presence (UP) means the authenticator confirmed that a human interacted with it — a physical touch, button press, or similar gesture. It answers: was someone there?

User Verification (UV) means the authenticator confirmed which human interacted with it, using a method local to the authenticator — biometric or PIN. It answers: was the right person there?

  UP only:  Someone physically touched the authenticator.
            Could be the legitimate user. Could be someone who grabbed the unlocked device.

  UV + UP:  The authenticator confirmed identity (biometric or PIN matched)
            AND confirmed physical presence. The right person was there.

The userVerification field in the request options controls what you ask for:

Value Meaning
"required" Authentication fails if UV flag is not set — enforce this for sensitive operations
"preferred" Request UV but don't fail if the authenticator doesn't support it
"discouraged" Don't ask for UV — useful for second-factor scenarios

On the server, after verifying the signature, check the UV flag if your operation requires it:

if (requiresHighAssurance && !authData.flags.userVerified) {
  throw new Error('User verification required for this operation');
}

💡 Mental Model: Think of UP as equivalent to a card swipe — something was presented. Think of UV as equivalent to a PIN entry or biometric scan — identity was confirmed. For high-value operations (fund transfers, changing account credentials), requiring UV raises the assurance bar meaningfully.

Where the Complexity Actually Lives

A recurring observation from teams implementing WebAuthn for the first time: the client-side code is surprisingly short. A complete registration and authentication flow on the front end is typically under fifty lines of JavaScript for most applications.

The server is a different story. The server must parse and decode CBOR-encoded attestation objects and authenticator data, verify signatures using whichever algorithm the authenticator chose from the pubKeyCredParams list, manage challenge state safely (stored server-side, tied to the session, protected against reuse, expired promptly), maintain a flexible multi-credential data model, and handle counter semantics correctly across device-bound and synced passkeys.

This is why the WebAuthn ecosystem has converged on server-side libraries that handle ceremony verification. Using a library does not mean you can skip understanding what the library is doing — the steps described in this walkthrough are what any correct implementation must perform, and knowing them lets you audit the library's behavior and handle edge cases.

📋 Quick Reference Card: Registration vs. Authentication

🔒 Registration 🔑 Authentication
🎯 Purpose Establish new credential Prove ownership of existing credential
📡 Server sends PublicKeyCredentialCreationOptions PublicKeyCredentialRequestOptions
🖥️ Browser calls navigator.credentials.create() navigator.credentials.get()
🔐 Key operation Authenticator generates key pair Authenticator signs challenge
📦 Response type AuthenticatorAttestationResponse AuthenticatorAssertionResponse
💾 Server stores Public key + credential ID + signCount (Updates signCount only)
✅ Server verifies Challenge, origin, RP ID hash, flags Signature, challenge, origin, RP ID hash, flags, counter

With the two ceremonies in hand, the next section addresses the errors developers most frequently make when moving from this conceptual understanding to an actual implementation.


Common Mistakes and Misunderstandings

Every authentication system has a gap between what the specification guarantees and what a developer actually ships. With WebAuthn, that gap tends to cluster around the same five failure points — not because the API is poorly designed, but because the mental models developers bring from password-based auth lead them astray in predictable ways.

Mistake 1: Weak, Reused, or Predictable Challenges

The challenge is the server-issued random value that the authenticator signs during both registration and authentication. Its security function is to prevent replay attacks: if an attacker captures a signed assertion, they cannot reuse it because the challenge bound to that signature will never appear again. That guarantee collapses the moment challenges are predictable or reused.

Common failure patterns:

  • Sequential integers — generating challenges as challenge = userId + timestamp or incrementing a counter lets an attacker predict the next challenge
  • Reusing the same challenge across requests — allows an attacker who intercepts one response to replay it on a second session where the same challenge is still valid
  • No expiry — a challenge stored indefinitely lets an attacker replay a captured assertion days later
INSECURE flow                    CORRECT flow
─────────────────────────────    ─────────────────────────────
Server generates challenge:      Server generates challenge:
  challenge = hash(userId)         challenge = crypto.randomBytes(32)
  (predictable, reused)            (unpredictable, unique per request)

Stored: indefinitely             Stored: in server session or cache
                                 Expires: after ~5 minutes
                                 Marked used: immediately on first verification

🎯 Key Principle: A valid challenge must be cryptographically random (from a CSPRNG, not Math.random() or a hash of user data), server-generated (never supplied by the client), single-use (invalidated the moment it is successfully verified), and short-lived (expire after a small time window — a few minutes is a reasonable upper bound).

After your server verifies an assertion response, the challenge it contained must be marked as consumed so that the same response cannot be submitted a second time.

Mistake 2: Conflating User Verification with User Presence

WebAuthn defines two distinct flags in the authenticator data, and conflating them is one of the most consequential assurance mistakes a relying party can make.

User Presence (UP) means a human physically interacted with the authenticator — they tapped a security key or touched a sensor. It proves someone was there. It does not prove who.

User Verification (UV) means the authenticator confirmed the identity of the user — via biometric check or PIN. It proves both that someone was there and that the authenticator verified which person.

                ┌─────────────────────────────────────┐
                │         Authenticator check         │
                └──────────────┬──────────────────────┘
                               │
             ┌─────────────────┴─────────────────┐
             │                                   │
      UP only (tap/button)             UV (biometric / PIN)
      "Someone touched it"             "This specific user confirmed"
             │                                   │
      authData.flags.up = true         authData.flags.up = true
      authData.flags.uv = false        authData.flags.uv = true
             │                                   │
      Low-assurance operations         High-assurance operations

The practical error: a developer reads that passkeys are "passwordless" and concludes that any successful assertion means the user is authenticated. They check response.verified === true and grant a session — without checking authData.flags.uv. On devices where user verification is not configured (a security key without a PIN, for example), uv will be false, and the only assurance is presence.

Wrong: "The passkey ceremony succeeded, so the user is authenticated."

Correct: "The ceremony succeeded. Now I check whether the assurance level matches what this operation requires. If UV is required and uv is false, I reject or step up."

For login and any sensitive operation, userVerification: "required" is almost always the right choice.

Mistake 3: Storing Only One Credential Per User

A user with a single registered passkey tied to one device has a fragile setup: if that device is lost, broken, or replaced, they cannot authenticate. The correct model is one-to-many: a user account holds a collection of credentials, each identified by its unique credential ID.

users
  └── user_id (PK)

credentials
  └── credential_id (PK)  ← opaque bytes from the authenticator
  └── user_id (FK)        ← links to users
  └── public_key          ← COSE-encoded public key
  └── sign_count          ← signature counter
  └── created_at
  └── last_used_at
  └── friendly_name       ← optional label ("iPhone 15", "YubiKey 5")

With this model, a user can register their laptop, phone, and a hardware security key — three credentials, one account — add a new device proactively before their old one breaks, and revoke a specific credential (the lost phone) without locking themselves out.

The single-credential mistake often surfaces when teams add passkey support to an existing user table by adding a passkey_public_key column directly. It seems convenient until the first user asks why they cannot log in from a second device.

Synced passkeys reduce the pressure on multi-device registration because the same passkey becomes accessible across a user's devices sharing a platform account — but they do not eliminate the need for a multi-credential model. Users may still want a hardware key as a backup, may use devices on different ecosystems, or may want to revoke a compromised synced credential and register a new one.

Mistake 4: Skipping RP ID and Origin Validation

WebAuthn's built-in phishing defense works because the browser embeds the requesting origin into the signed authenticator data. But that defense only holds if the server actually checks the origin and RP ID. Skipping or superficially validating these fields reopens the cross-origin attack surface the protocol was designed to close.

Here is what must happen on the server for every assertion response:

Assertion response received
         │
         ▼
1. Parse clientDataJSON
   └── type == "webauthn.get"?          ← reject if not
   └── challenge matches issued value?   ← reject if not, mark used
   └── origin == expected origin?        ← REJECT IF NOT ⚠️
         │
         ▼
2. Parse authenticatorData
   └── rpIdHash == SHA-256(expected RP ID)?  ← REJECT IF NOT ⚠️
   └── UP flag set?                           ← reject if not
   └── UV flag set? (if required)             ← reject if required but unset
         │
         ▼
3. Verify signature
   └── sig over (authData || SHA-256(clientDataJSON))
   └── using stored public key for this credential ID
         │
         ▼
4. Check and update signature counter
         │
         ▼
Grant session

Origin and RP ID hash validation (steps 1 and 2) are frequently omitted by developers who implement verification manually and test only the happy path. In a test environment where the origin is always the same, skipping the check produces no visible failure — and so the gap ships to production.

🎯 Key Principle: Use a well-tested server-side WebAuthn library rather than implementing verification from scratch. The validation sequence has multiple steps that must all execute correctly and in the right order. A library handles these checks as a unit; rolling your own means each step is a separate opportunity to introduce a gap.

Mistake 5: No Account Recovery Path

Passkeys eliminate the problems that come from shared secrets — but they introduce a new category of lockout risk. A password can be reset via email because the password is one secret among many ways to prove ownership. A passkey is a private key that exists only on the authenticator. If a user loses all their registered devices and has no recovery method in place, they are locked out.

Password-based recovery             Passkey recovery (must be designed)
─────────────────────────────       ─────────────────────────────────────
"Forgot password" →                 Options:
  verify email →                      1. Backup codes (generated at
  set new password                        registration, stored by user)
                                        2. Recovery email/phone with
                                           step-up verification
                                        3. Pre-registered backup
                                           authenticator (hardware key)
                                        4. Identity verification via
                                           customer support (with fraud controls)

The recovery strategy needs to match the assurance level of the original authentication. A high-security application that requires UV at login cannot recover via a simple email link without reducing the overall security of the system — the weakest link in the account's lifecycle determines the effective security posture.

💡 Mental Model: Think of passkey deployment as having two distinct problems: authentication (how do users prove identity day-to-day?) and recovery (how do users regain access when their authenticators are unavailable?). WebAuthn solves the first problem completely. It does not solve the second — that design is your responsibility.

Practical recovery approaches worth considering: backup codes generated at registration time; prompting users to register a second device or hardware key during onboarding (the multi-credential data model makes this straightforward); recognizing that synced passkeys provide implicit recovery if the user's other devices share the same platform account; and maintaining a fallback authentication method during the passkey rollout period.

⚠️ Common Mistake: Presenting passkey enrollment as optional and fallback-only, which means most users never enroll one. If passkeys remain a small-minority option, you get none of the credential-stuffing and phishing resistance that motivated the deployment. Recovery design and enrollment incentives need to be considered together.

Pre-Launch Checklist

Before shipping a WebAuthn implementation, these five areas are the highest-value things to audit:

📋 Quick Reference Card: Implementation Integrity Checks

Area Wrong Right
🎲 Challenge Predictable, reused, or never expires CSPRNG-generated, single-use, short TTL
🔍 User Verification Checking verified only Checking uv flag against operation's assurance requirement
🗂️ Credential storage One credential per user Many credentials per user, keyed by credential ID
🌐 Origin + RP ID Skipped or partially checked Both validated on every assertion, server-side
🔑 Recovery None or email-reset only Explicit recovery path matched to assurance level

🧠 Mnemonic: CUREChallenge integrity, User verification level, Register multiple credentials, Exit path (recovery). These four concerns cover the most common WebAuthn implementation gaps.


Key Takeaways and What Comes Next

This lesson has covered substantial ground — from the structural failure modes of passwords, through the cryptographic mechanics of public-key credentials and attestation, through the actors and data structures of the WebAuthn ecosystem, through the full registration and authentication ceremonies, and through the implementation mistakes that most reliably produce security gaps. This final section consolidates the core ideas into a durable mental framework and maps the terrain ahead.

The Three Properties That Make the Rest Legible

Every design choice in the WebAuthn API — every field in every options object, every flag in every assertion response — is a consequence of three foundational security properties. If you understand these three, the API design stops feeling arbitrary and starts feeling inevitable.

  1. Private keys never leave the authenticator. The key material is generated inside the authenticator and is not exported. The server never sees it. A network eavesdropper never sees it. This is not a policy — it is an architectural constraint enforced by hardware or secure enclave.

  2. Credentials are origin-scoped. A credential registered on bank.example is cryptographically tied to that origin. The browser includes the requesting origin in the data the authenticator signs, which means a credential cannot authenticate against evil-bank.example even if the user is tricked into visiting the malicious site.

  3. The server stores no secret. The relying party stores a public key and a credential ID. If an attacker breaches the server's database, they have gained nothing useful for impersonating users.

🧠 Mnemonic: "Never, Scoped, None" — the private key never leaves, credentials are scoped to an origin, the server holds none of the secret.

These three properties are mutually reinforcing: credential stuffing fails because there is no reusable password to stuff; phishing fails because origin scoping prevents cross-origin credential use; breach exposure fails because the server holds no secret worth stealing.

The Two Operations and Their Structural Symmetry

WebAuthn exposes exactly two operations: registration (navigator.credentials.create()) and authentication (navigator.credentials.get()). Both follow the same four-step shape:

Server                          Browser / Authenticator
──────                          ───────────────────────
1. Generate challenge
   + assemble options object  ──▶  2. Call credentials API
                                      with options object

                               ◀──  3. Authenticator produces
                                      signed response

4. Verify signature
   + update server state
🔑 Registration (create) 🔓 Authentication (get)
📤 Server sends PublicKeyCredentialCreationOptions PublicKeyCredentialRequestOptions
📥 Authenticator returns AuthenticatorAttestationResponse AuthenticatorAssertionResponse
🔧 Authenticator action Generates new key pair Signs challenge with existing private key
💾 Server stores Public key + credential ID Updates signature counter
🔒 Primary security check Verify attestation (optional) + origin Verify signature + origin + counter

The challenge appears in both operations and serves the same purpose: a server-generated random value that must be signed, ensuring the response is fresh and cannot be replayed.

Authenticator Types and Passkey Variants: Not Interchangeable

The platform-vs-roaming and synced-vs-device-bound distinctions are not cosmetic — they affect recoverability, portability, and threat model in ways that directly drive deployment decisions.

Platform authenticators (built-in biometrics) offer frictionless UX but are tied to a specific device. Roaming authenticators (security keys) are portable across devices but are physical objects that can be lost.

Synced passkeys dramatically improve recoverability — a user who gets a new phone does not need to re-register — but the security of the credential is tied to the security of the platform account. Device-bound passkeys offer stronger assurance about where the private key can ever be, which matters in regulated environments, but loss of the device means loss of the credential.

 Synced Passkeys                    Device-Bound Passkeys
 ───────────────                    ─────────────────────
 ✅ Easy recovery (cross-device)    ✅ No cloud sync attack surface
 ✅ Works after device loss          ✅ Credential provably stays local
 ⚠️ Platform account enters          ⚠️ Loss of device = loss of
    the trust chain                     credential
 ⚠️ Platform vendor controls        ✅ Meets high-assurance
    sync infrastructure                 compliance requirements

The right choice depends on who your users are, what access you are protecting, and what your recovery path looks like — and it is a decision that is difficult to reverse once users are enrolled.

What the Upcoming Lessons Cover

WebAuthn Ceremonies will go into the exact byte-level structure of each ceremony — what fields are parsed, in what order, what validations are mandatory versus optional, and what a correct implementation looks like at each step. If this lesson answered "what is happening and why," the ceremonies lesson answers "what exactly must my server do, and in what order."

Passkey Deployment will address the practical questions that are easy to underestimate until you face real users: how to introduce passkeys alongside existing authentication methods, how to handle account recovery at scale, how to manage multiple credentials per user across a fleet of devices, and how to communicate the new flow to users who have never encountered a passkey prompt before.

⚠️ Critical point to carry forward: The three security properties — never leaves, origin-scoped, no server secret — are not just background knowledge. They are the lens through which every implementation decision in those upcoming lessons becomes legible. When a server validation step seems pedantic, the answer will always trace back to one of the three properties. Keep that lens active as you move forward.

The shift from passwords to passkeys is not primarily a UX improvement, though it is that too. It is a structural change to where secrets live and who can be breached without consequence. You now understand why that is true. The next lessons are about making it real.