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:
- The browser asks the user’s device (phone, laptop, security key) to generate a key pair.
- The public key is sent to your server and stored alongside the user account.
- The private key never leaves the device.
When the user authenticates:
- Your server sends a random challenge.
- The browser prompts the user to unlock their device (Face ID, Touch ID, Windows Hello, or PIN).
- The device signs the challenge with the private key.
- 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:
- Add a “Set up passkey” button in account settings for existing users.
- After a password login, prompt the user to add a passkey for next time.
- 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:
- Store challenges with a short TTL (60–120 seconds) and mark them consumed after use. Replay attacks are only prevented if the challenge can’t be reused.
- Verify the origin in
clientDataJSONmatches your expected origin exactly (including protocol and port). This prevents cross-origin attacks. - Verify the RP ID in the authenticator data matches your
rpId. This prevents credential reuse across subdomains. - Track the signature counter returned in the authenticator data. If it doesn’t increase between authentications, the credential may have been cloned.
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.