Passkeys are the first mainstream login method that's both more secure and more convenient than a password, which is a combination the industry hasn't managed before.
Passkeys are a passwordless authentication method built on the WebAuthn standard, using public-key cryptography instead of a shared secret. A private key stays on the user's device (secured by biometrics or a device PIN), and only the public key is ever sent to your server — there's no password to phish, leak in a breach, or reuse across sites, because there's nothing shared to steal in the first place.
Why Passkeys Matter (and When to Skip Them)
Password-based auth has a structural problem: the server must store something derived from the password (a hash), and that hash can be stolen and cracked. Passkeys eliminate this entirely — the server only ever stores a public key, which is useless to an attacker without the corresponding private key that never leaves the user's device. They're also phishing-resistant by design: a passkey is cryptographically bound to the origin it was created for, so a fake login page simply can't request it.
Skip passkeys as your only auth method for now — device and browser support is broad but not universal, and users unfamiliar with the concept need a fallback. Offer them alongside password or magic-link auth rather than replacing those entirely, at least for the next few years of adoption.
Getting Started with Passkeys
Registration (client-side, using the WebAuthn browser API via a library like @simplewebauthn/browser):
import { startRegistration } from "@simplewebauthn/browser";
async function registerPasskey(userId: string) {
const optionsRes = await fetch("/api/passkeys/register-options", { method: "POST", body: JSON.stringify({ userId }) });
const options = await optionsRes.json();
const credential = await startRegistration(options); // triggers biometric/PIN prompt
await fetch("/api/passkeys/register-verify", {
method: "POST",
body: JSON.stringify({ userId, credential }),
});
}
Server-side, using @simplewebauthn/server:
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
const options = await generateRegistrationOptions({
rpName: "Your App",
rpID: "yourapp.com",
userID: Buffer.from(userId),
userName: userEmail,
});
// store options.challenge temporarily, associated with this user, for verification
Core Passkey Concepts Every Developer Should Know
The relying party ID (rpID) binds a passkey to a specific domain. This is the mechanism behind phishing resistance — a passkey created for yourapp.com simply cannot be used to authenticate on y0urapp.com, no matter how convincing the fake site looks, because the browser enforces the origin check before the credential is even offered.
Passkeys sync across a user's devices via platform ecosystems (iCloud Keychain, Google Password Manager) — a passkey created on one device is available on other devices signed into the same account, without extra setup.
Discoverable credentials remove the need to even ask for a username. With residentKey: "required" at registration, the browser can look up which passkey matches a given site during login, letting users authenticate with just a biometric prompt — no username field at all.
Verification involves checking the signature against the stored public key, plus origin and challenge validation, to confirm the response came from the genuine device for the genuine site:
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: storedChallenge,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
authenticator: storedCredential,
});
Common Passkey Mistakes and How to Fix Them
Mistake 1: no fallback auth method. Requiring passkeys exclusively locks out users on unsupported browsers, older devices, or users who simply find the concept unfamiliar. Fix: offer passkeys as the recommended option alongside a traditional fallback (password, magic link) during the adoption period.
Mistake 2: not handling multi-device/multi-passkey users correctly. A user might register a passkey on their phone and another on their laptop — your schema needs to support multiple credentials per user, not just one. Fix: model the relationship as one-to-many between users and passkey credentials from the start.
Mistake 3: implementing WebAuthn's low-level API directly instead of a vetted library. The raw navigator.credentials API and its cryptographic verification steps have enough subtlety that hand-rolled implementations commonly get challenge/origin validation wrong. Fix: use @simplewebauthn or your auth provider's built-in passkey support (Clerk, Better Auth both support it) rather than implementing WebAuthn from scratch.
When Should You Add Passkey Support?
Add it as an option for any app with a real security profile — anywhere credential theft or phishing is a meaningful risk (finance, healthcare, admin panels), and as a convenience upgrade anywhere else. Most managed auth providers (Clerk, Better Auth, Supabase Auth) now support passkeys as a near drop-in addition, which lowers the implementation cost significantly.
Passkeys in Production
Prompt users to add a passkey after a successful traditional login, rather than only at signup — this "progressive enhancement" pattern gets far higher adoption than asking at signup when users are already making several other decisions. Also make sure your account recovery flow doesn't quietly reintroduce a password-equivalent weak link (like an SMS-only recovery code) that undermines the phishing resistance passkeys were meant to provide.
If your auth provider supports passkeys already (most modern ones do), turn it on as an option this quarter — the implementation cost is low relative to the real security and UX improvement.