Passwords are the source of most account compromises: reused across sites, phished, leaked in breaches, or guessed. Passkeys eliminate the problem at the root. Instead of a string the user must remember and a server must store, a passkey is a cryptographic key pair — the private key stays on the user’s device, the public key goes on your server, and authentication happens without transmitting anything that can be stolen.

The underlying technology is WebAuthn, a W3C standard that has been part of all major browsers since 2019. In 2026, with Apple, Google, and Microsoft all promoting passkeys as the default sign-in method on their platforms, the adoption window for web developers is now. This guide explains what WebAuthn is, how passkeys work, and how to add them to a web app.

How It Works: Public Key Cryptography in 20 Seconds

When a user registers a passkey:

  1. The browser asks the user’s device (phone, laptop, security key) to generate a key pair.
  2. The public key is sent to your server and stored alongside the user account.
  3. The private key never leaves the device.

When the user authenticates:

  1. Your server sends a random challenge.
  2. The browser prompts the user to unlock their device (Face ID, Touch ID, Windows Hello, or PIN).
  3. The device signs the challenge with the private key.
  4. Your server verifies the signature using the stored public key.

There is no password to phish. There is no shared secret to steal from your database. The signature is only valid for the exact challenge your server generated, so replaying it does nothing.

Registration: Creating a Passkey

On the client, registration calls navigator.credentials.create() with a PublicKeyCredentialCreationOptions object your server generates:

// 1. Server generates options (send these to the client)
const registrationOptions = {
  challenge: crypto.getRandomValues(new Uint8Array(32)), // random bytes
  rp: { name: "My App", id: "myapp.example.com" },
  user: {
    id: new TextEncoder().encode(userId),
    name: userEmail,
    displayName: userName
  },
  pubKeyCredParams: [
    { type: "public-key", alg: -7 },   // ES256 (preferred)
    { type: "public-key", alg: -257 }  // RS256 (fallback)
  ],
  authenticatorSelection: {
    residentKey: "preferred",   // store credential on device
    userVerification: "preferred" // prompt biometric/PIN
  },
  timeout: 60000
};

// 2. Client calls the browser API
const credential = await navigator.credentials.create({
  publicKey: registrationOptions
});

// 3. Send credential to server for verification and storage
const registrationData = {
  id: credential.id,
  rawId: Array.from(new Uint8Array(credential.rawId)),
  response: {
    clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
    attestationObject: Array.from(new Uint8Array(credential.response.attestationObject))
  },
  type: credential.type
};
await fetch("/auth/register", { method: "POST", body: JSON.stringify(registrationData) });

On the server, you parse the attestation object, verify the challenge matches what you generated, extract and store the public key. Libraries like SimpleWebAuthn handle this parsing safely in Node.js.

Authentication: Using a Passkey

Login uses navigator.credentials.get() with options from your server:

// 1. Server generates a new random challenge
const authOptions = {
  challenge: crypto.getRandomValues(new Uint8Array(32)),
  rpId: "myapp.example.com",
  allowCredentials: [{
    type: "public-key",
    id: storedCredentialId  // the credential ID from registration
  }],
  userVerification: "preferred",
  timeout: 60000
};

// 2. Browser prompts Face ID / Touch ID / Windows Hello
const assertion = await navigator.credentials.get({
  publicKey: authOptions
});

// 3. Send assertion to server for verification
const authData = {
  id: assertion.id,
  rawId: Array.from(new Uint8Array(assertion.rawId)),
  response: {
    clientDataJSON: Array.from(new Uint8Array(assertion.response.clientDataJSON)),
    authenticatorData: Array.from(new Uint8Array(assertion.response.authenticatorData)),
    signature: Array.from(new Uint8Array(assertion.response.signature))
  }
};
const result = await fetch("/auth/login", { method: "POST", body: JSON.stringify(authData) });

The server verifies the signature against the stored public key and the challenge. If it matches, the user is authenticated. Issue a session token as you normally would.

Discoverable Credentials: Login Without a Username

Passkeys with residentKey: "required" are stored on the device in a way that lets the user sign in without even typing a username. The browser shows a list of available passkeys for your site, the user selects one, and authentication completes.

On the client, just omit allowCredentials from the auth options. The browser handles discovery. On the server, look up the user by the credential ID returned in the assertion, since you don’t know the user upfront.

Progressive Enhancement: Passkeys Alongside Passwords

You don’t need to drop passwords on day one. The recommended migration path:

  1. Add a “Set up passkey” button in account settings for existing users.
  2. After a password login, prompt the user to add a passkey for next time.
  3. Once a user has a passkey, default to passkey login and show the password option as a fallback.

Check for browser support before showing the passkey UI:

const passkeysSupported =
  window.PublicKeyCredential &&
  await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();

if (passkeysSupported) {
  showPasskeyOption();
}

The Server-Side Checklist

A few things you must handle correctly on the server:

Using a well-maintained library for verification (SimpleWebAuthn for Node, py_webauthn for Python, go-webauthn for Go) is strongly recommended. The parsing and validation logic has meaningful complexity that is easy to get wrong.

Why Users Actually Like It

The user experience of passkeys is genuinely better than passwords in the scenarios that matter most. Sign-in on mobile is a single biometric tap — no typing, no password manager required. Sign-in on a new device uses iCloud Keychain or Google Password Manager to sync the passkey automatically. There is no password reset flow because there is nothing to forget.

Phishing resistance is the security win, but the UX improvement is what drives adoption. Users who try passkeys rarely want to go back to passwords. That combination makes it worth adding to your web app now, even before passwords are ready to retire.