I finally added passkey support to a side project last month, and I'm kicking myself for not doing it sooner. The UX improvement is dramatic — users authenticate with a fingerprint or face scan instead of typing a password. Here's how to implement it from scratch.
What Are Passkeys?
Passkeys are the consumer-friendly name for WebAuthn/FIDO2 credentials. Instead of a password, the user's device generates a public-private key pair. The private key never leaves the device. Authentication is a cryptographic challenge-response — no shared secrets, nothing to phish.
The key insight: passkeys are synced across devices via iCloud Keychain (Apple), Google Password Manager (Android/Chrome), or Windows Hello. This solves the old "I registered my security key on my laptop but I'm on my phone" problem.
Browser Support in 2026
We're in great shape:
| Browser | Passkey Support | Synced Passkeys |
|---|---|---|
| Chrome 118+ | Yes | Via Google Password Manager |
| Safari 16.4+ | Yes | Via iCloud Keychain |
| Firefox 122+ | Yes | Via third-party managers |
| Edge 118+ | Yes | Via Windows Hello / Google PM |
Practically speaking, 95%+ of users on current browsers can use passkeys. The main gap is older Android devices without a biometric sensor, and Firefox on Linux (improving, but still rough).
Prerequisites
You'll need a server library. I'm using @simplewebauthn/server and @simplewebauthn/browser, which are well-maintained and follow the spec closely.
npm install @simplewebauthn/server @simplewebauthn/browser
Your app also needs to be served over HTTPS (even in development — use a tool like mkcert for local certs). WebAuthn won't work over plain HTTP.
Step 1: Registration (Creating a Passkey)
Registration has three phases: generate options, create credential on client, verify on server.
Server: Generate Registration Options
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
const rpName = 'My App';
const rpID = 'myapp.com'; // Your domain
const origin = 'https://myapp.com';
app.post('/auth/passkey/register/options', async (req, res) => {
const user = req.user; // Must be authenticated already
// Get existing credentials to exclude (prevent duplicate registration)
const existingCredentials = await db.getCredentialsForUser(user.id);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(user.id),
userName: user.email,
userDisplayName: user.name || user.email,
// Exclude existing credentials so user doesn't register the same device twice
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'preferred', // Allow discoverable credentials
userVerification: 'preferred', // Biometric if available
},
});
// Store the challenge for verification
await db.storeChallenge(user.id, options.challenge);
res.json(options);
});
Client: Create Credential
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
// 1. Get options from server
const optionsRes = await fetch('/auth/passkey/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await optionsRes.json();
// 2. Trigger the browser's WebAuthn dialog
let credential;
try {
credential = await startRegistration({ optionsJSON: options });
} catch (err) {
if (err.name === 'NotAllowedError') {
// User cancelled the dialog
return { error: 'Registration cancelled' };
}
throw err;
}
// 3. Send credential to server for verification
const verifyRes = await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
});
return verifyRes.json();
}
Server: Verify Registration
app.post('/auth/passkey/register/verify', async (req, res) => {
const user = req.user;
const expectedChallenge = await db.getChallenge(user.id);
try {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
// Store the credential
await db.storeCredential({
userId: user.id,
credentialID: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
deviceType: verification.registrationInfo.credentialDeviceType,
backedUp: verification.registrationInfo.credentialBackedUp,
createdAt: new Date(),
});
res.json({ verified: true });
} else {
res.status(400).json({ error: 'Verification failed' });
}
} catch (err) {
res.status(400).json({ error: err.message });
}
});
Step 2: Authentication (Using a Passkey)
Authentication follows the same pattern: generate challenge, sign on client, verify on server.
Server: Generate Authentication Options
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
app.post('/auth/passkey/login/options', async (req, res) => {
const { email } = req.body;
// For discoverable credentials, you can omit allowCredentials
// to let the user pick from any passkey on their device
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
// Optionally limit to specific user's credentials:
// allowCredentials: userCredentials.map(c => ({ id: c.credentialID, type: 'public-key' })),
});
// Store challenge (use session or cache, keyed by a temporary ID)
const challengeId = crypto.randomUUID();
await db.storeChallenge(challengeId, options.challenge);
res.json({ ...options, challengeId });
});
Client: Authenticate
import { startAuthentication } from '@simplewebauthn/browser';
async function loginWithPasskey() {
// 1. Get options
const optionsRes = await fetch('/auth/passkey/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.value }),
});
const options = await optionsRes.json();
// 2. Trigger authentication
let credential;
try {
credential = await startAuthentication({ optionsJSON: options });
} catch (err) {
if (err.name === 'NotAllowedError') {
return { error: 'Authentication cancelled' };
}
throw err;
}
// 3. Verify on server
const verifyRes = await fetch('/auth/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...credential,
challengeId: options.challengeId,
}),
});
const result = await verifyRes.json();
if (result.verified) {
window.location.href = '/dashboard';
}
}
Server: Verify Authentication
app.post('/auth/passkey/login/verify', async (req, res) => {
const { challengeId, ...credential } = req.body;
const expectedChallenge = await db.getChallenge(challengeId);
// Look up the credential
const storedCredential = await db.getCredentialById(credential.id);
if (!storedCredential) {
return res.status(401).json({ error: 'Unknown credential' });
}
try {
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: storedCredential.credentialID,
publicKey: storedCredential.publicKey,
counter: storedCredential.counter,
},
});
if (verification.verified) {
// Update the counter (important for detecting cloned authenticators)
await db.updateCredentialCounter(
storedCredential.credentialID,
verification.authenticationInfo.newCounter
);
// Create session
req.session.userId = storedCredential.userId;
res.json({ verified: true });
} else {
res.status(401).json({ error: 'Verification failed' });
}
} catch (err) {
res.status(401).json({ error: err.message });
}
});
Step 3: Handling Fallbacks
Not every user can use passkeys. You need a fallback.
// Check if WebAuthn is available
function isPasskeySupported() {
return !!(
window.PublicKeyCredential &&
typeof window.PublicKeyCredential === 'function'
);
}
// Check if the platform supports conditional UI (autofill)
async function isConditionalUIAvailable() {
if (!isPasskeySupported()) return false;
return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
}
// Build your login form accordingly
async function initLoginForm() {
const supportsPasskeys = isPasskeySupported();
const supportsAutofill = await isConditionalUIAvailable();
if (supportsAutofill) {
// Best UX: passkey autofill in the username field
showAutofilledPasskeyLogin();
} else if (supportsPasskeys) {
// Good UX: explicit "Sign in with passkey" button
showPasskeyButton();
}
// Always show email/password as fallback
showEmailPasswordForm();
}
Conditional UI (Passkey Autofill)
This is the smoothest UX. The browser shows passkey suggestions in the autofill dropdown, just like saved passwords:
<input
type="text"
id="email"
autocomplete="username webauthn"
placeholder="Email address"
/>
// Start conditional (autofill) authentication
async function startConditionalAuth() {
const optionsRes = await fetch('/auth/passkey/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const options = await optionsRes.json();
try {
const credential = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});
// User selected a passkey from autofill — verify it
await verifyAndLogin(credential);
} catch (err) {
// User chose password instead, or no passkey available
console.log('Conditional auth not completed:', err);
}
}
UX Best Practices
Don't force passkeys. Offer them alongside existing auth methods. Let users discover and adopt them naturally.
Prompt for registration after login. Once a user is authenticated (via password), show a "Add a passkey for faster login next time" prompt. Don't interrupt the signup flow.
Show what's registered. Give users a settings page where they can see their registered passkeys and delete them:
// Settings page API
app.get('/auth/passkeys', async (req, res) => {
const credentials = await db.getCredentialsForUser(req.user.id);
res.json(credentials.map(c => ({
id: c.credentialID,
deviceType: c.deviceType,
backedUp: c.backedUp,
createdAt: c.createdAt,
lastUsed: c.lastUsed,
})));
});
Require at least one other auth method. Don't let a user's only login method be a passkey — if they lose access to their devices and their passkeys aren't synced, they're locked out.
Communicate clearly. Many users don't know what a passkey is. Use language like "Sign in with your fingerprint or face" rather than "WebAuthn FIDO2 credential."
If You Don't Want to Build From Scratch
Implementing passkeys correctly involves a lot of edge cases: attestation formats, credential management, cross-device flows, account recovery. If you don't want to handle all of this yourself, tools like Authon and Clerk offer built-in passkey support that you can integrate in an afternoon. They handle the WebAuthn ceremony, credential storage, and fallback flows, so you can focus on your actual product.
That said, the @simplewebauthn library makes the from-scratch approach very manageable. I'd recommend trying it at least once to understand what's happening under the hood.
Common Pitfalls
- Forgetting to update the counter. The signature counter prevents credential cloning. Always update it after verification.
-
Using
localhostwithout HTTPS. WebAuthn requires a secure context. Usemkcertfor local development. -
Not handling
NotAllowedError. This fires when the user cancels the dialog. Don't show an error — just do nothing. -
Setting
userVerification: 'required'globally. This excludes devices without biometrics. Use'preferred'unless you have a specific security requirement. - Not testing on multiple platforms. The WebAuthn UX differs significantly between Chrome on Mac, Safari on iOS, and Chrome on Android. Test all of them.
Wrapping Up
Passkeys are the most significant improvement to web authentication in years. They're phishing-resistant, faster than passwords, and users genuinely prefer them once they try it. The spec is stable, browser support is excellent, and the developer tooling is mature.
If you've been putting off adding passkey support, now's the time. Start with registration for existing users, add the conditional UI for login, and keep email/password as a fallback. Your users will thank you.
Top comments (0)