Passkeys & Passwordless Authentication: Complete Guide 2026
The password problem in 2026
Passwords have been the dominant authentication method for decades, but the model is fundamentally broken. Every year billions of credentials are leaked in data breaches, and the majority of users reuse the same passwords across multiple services.
Phishing attacks have become so sophisticated that even technical users fall for them. A well-crafted email, a cloned login page, and credentials are compromised. Multi-factor authentication (MFA) mitigates some risk, but adds friction and remains vulnerable to real-time attacks like SIM swapping and MFA fatigue.

What are Passkeys (FIDO2/WebAuthn)
Passkeys are cryptographic credentials based on the FIDO2 standard and the WebAuthn API. Instead of sharing a secret (the password) with the server, passkeys use public-key cryptography: your device generates a key pair (public and private), and only the public key is stored on the server.
When you authenticate, the server sends a challenge that your device signs with the private key. The server verifies the signature using the stored public key. The private key never leaves your device, making traditional phishing impossible.
Key components of the flow
| Component | Role |
|---|---|
| Relying Party (RP) | Your web application — the server that requests authentication |
| Authenticator | The device that generates and stores keys (phone, laptop, USB key) |
| Client | The browser that mediates between the RP and the Authenticator via the WebAuthn API |
Platform vs. cross-platform authenticators
There are two types of FIDO2 authenticators you need to understand when designing your passkey strategy:
- Platform authenticators: Built into the device — Touch ID, Face ID, Windows Hello, Android fingerprint sensor. The private key is stored in the device's security chip (TPM, Secure Enclave).
- Cross-platform (roaming) authenticators: External devices like USB security keys (YubiKey, Google Titan). You can carry them with you and use them on any computer.

Implementing WebAuthn registration in TypeScript
Let's implement the full passkey registration flow using @simplewebauthn/server and @simplewebauthn/browser. Starting with the server:
1# Install dependencies
2npm install @simplewebauthn/server @simplewebauthn/browser
3npm install -D @simplewebauthn/types
First, the endpoint that generates registration options:
1// app/api/auth/register/options/route.ts
2import {
3 generateRegistrationOptions,
4 type GenerateRegistrationOptionsOpts,
5} from '@simplewebauthn/server';
6
7const RP_NAME = 'My Secure App';
8const RP_ID = 'myapp.com';
9
10export async function POST(request: Request) {
11 const { userId, userEmail } = await request.json();
12
13 // Get existing passkeys for the user (to exclude them)
14 const existingCredentials = await db.credential.findMany({
15 where: { userId },
16 select: { credentialId: true, transports: true },
17 });
18
19 const opts: GenerateRegistrationOptionsOpts = {
20 rpName: RP_NAME,
21 rpID: RP_ID,
22 userName: userEmail,
23 userDisplayName: userEmail,
24 // Don't prompt for already registered passkeys
25 excludeCredentials: existingCredentials.map((c) => ({
26 id: c.credentialId,
27 transports: c.transports,
28 })),
29 authenticatorSelection: {
30 residentKey: 'preferred',
31 userVerification: 'preferred',
32 },
33 attestationType: 'none',
34 };
35
36 const options = await generateRegistrationOptions(opts);
37
38 // Save challenge in session for later verification
39 await saveChallenge(userId, options.challenge);
40
41 return Response.json(options);
42}
This endpoint generates a random challenge and the options the browser needs to create a new credential. Note that we exclude existing credentials to prevent duplicates.
Server-side registration verification
Once the browser creates the credential, we need to verify it on the server:
1// app/api/auth/register/verify/route.ts
2import {
3 verifyRegistrationResponse,
4 type VerifiedRegistrationResponse,
5} from '@simplewebauthn/server';
6
7export async function POST(request: Request) {
8 const body = await request.json();
9 const { userId } = body;
10
11 // Retrieve the saved challenge
12 const expectedChallenge = await getChallenge(userId);
13 if (!expectedChallenge) {
14 return Response.json({ error: 'Challenge expired' }, { status: 400 });
15 }
16
17 let verification: VerifiedRegistrationResponse;
18 try {
19 verification = await verifyRegistrationResponse({
20 response: body.credential,
21 expectedChallenge,
22 expectedOrigin: 'https://myapp.com',
23 expectedRPID: 'myapp.com',
24 });
25 } catch (error) {
26 console.error('Verification error:', error);
27 return Response.json({ error: 'Verification failed' }, { status: 400 });
28 }
29
30 const { verified, registrationInfo } = verification;
31
32 if (verified && registrationInfo) {
33 // Save the credential in the database
34 await db.credential.create({
35 data: {
36 credentialId: registrationInfo.credential.id,
37 publicKey: Buffer.from(registrationInfo.credential.publicKey),
38 counter: registrationInfo.credential.counter,
39 transports: body.credential.response.transports ?? [],
40 userId,
41 },
42 });
43
44 return Response.json({ verified: true });
45 }
46
47 return Response.json({ error: 'Not verified' }, { status: 400 });
48}
counter. WebAuthn uses a counter that increments with each use — if you receive a value lower than the stored one, it could indicate the credential was cloned.
Authentication flow (login with passkeys)
The authentication flow is similar to registration but uses generateAuthenticationOptions and verifyAuthenticationResponse. Let's see the complete client-side implementation:
1// lib/passkey-client.ts
2import {
3 startRegistration,
4 startAuthentication,
5} from '@simplewebauthn/browser';
6
7export async function registerPasskey(userId: string, email: string) {
8 // 1. Get options from the server
9 const optionsRes = await fetch('/api/auth/register/options', {
10 method: 'POST',
11 headers: { 'Content-Type': 'application/json' },
12 body: JSON.stringify({ userId, userEmail: email }),
13 });
14 const options = await optionsRes.json();
15
16 // 2. Create the credential with the device's authenticator
17 const credential = await startRegistration({ optionsJSON: options });
18
19 // 3. Send the credential to the server for verification
20 const verifyRes = await fetch('/api/auth/register/verify', {
21 method: 'POST',
22 headers: { 'Content-Type': 'application/json' },
23 body: JSON.stringify({ userId, credential }),
24 });
25
26 return verifyRes.json();
27}
28
29export async function loginWithPasskey() {
30 // 1. Get authentication options
31 const optionsRes = await fetch('/api/auth/login/options', {
32 method: 'POST',
33 });
34 const options = await optionsRes.json();
35
36 // 2. Browser prompts for biometric/PIN verification
37 const assertion = await startAuthentication({ optionsJSON: options });
38
39 // 3. Verify on the server
40 const verifyRes = await fetch('/api/auth/login/verify', {
41 method: 'POST',
42 headers: { 'Content-Type': 'application/json' },
43 body: JSON.stringify(assertion),
44 });
45
46 return verifyRes.json();
47}
48
49// Check if the browser supports WebAuthn
50export function isWebAuthnSupported(): boolean {
51 return (
52 typeof window !== 'undefined' &&
53 typeof window.PublicKeyCredential !== 'undefined'
54 );
55}
56
57// Check if Conditional UI (autofill) is supported
58export async function supportsConditionalUI(): Promise<boolean> {
59 if (!isWebAuthnSupported()) return false;
60 return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
61}
Conditional UI: Passkeys in the autofill dropdown
One of the best user experiences with passkeys is Conditional UI, which shows available passkeys directly in the browser's autofill dropdown. The user simply clicks their passkey, verifies with biometrics, and they're in.
To enable Conditional UI, you need to add webauthn to the autocomplete attribute of the username field:
1// components/LoginForm.tsx
2'use client';
3
4import { useEffect, useState } from 'react';
5import { startAuthentication } from '@simplewebauthn/browser';
6
7export function LoginForm() {
8 const [email, setEmail] = useState('');
9 const [isConditionalAvailable, setConditionalAvailable] = useState(false);
10
11 useEffect(() => {
12 async function initConditionalUI() {
13 const available =
14 await PublicKeyCredential.isConditionalMediationAvailable?.();
15 if (!available) return;
16 setConditionalAvailable(true);
17
18 try {
19 const optionsRes = await fetch('/api/auth/login/options', {
20 method: 'POST',
21 });
22 const options = await optionsRes.json();
23
24 // useBrowserAutofill: true enables Conditional UI
25 const assertion = await startAuthentication({
26 optionsJSON: options,
27 useBrowserAutofill: true,
28 });
29
30 const verifyRes = await fetch('/api/auth/login/verify', {
31 method: 'POST',
32 headers: { 'Content-Type': 'application/json' },
33 body: JSON.stringify(assertion),
34 });
35
36 if (verifyRes.ok) {
37 window.location.href = '/dashboard';
38 }
39 } catch (e) {
40 console.log('Conditional UI cancelled:', e);
41 }
42 }
43
44 initConditionalUI();
45 }, []);
46
47 return (
48 <form>
49 <input
50 type="email"
51 value={email}
52 onChange={(e) => setEmail(e.target.value)}
53 autoComplete="username webauthn"
54 placeholder="[email protected]"
55 />
56 <input type="password" autoComplete="current-password" />
57 <button type="submit">Sign in</button>
58 </form>
59 );
60}
Passkey management UX
A great user experience requires that users can manage their passkeys: see which ones are registered, add new ones, and remove those they no longer need.
Key UX aspects for passkey management:
- Show device and date: Indicate which device each passkey was created on and when it was last used.
- Rename passkeys: Let users give descriptive names like "Work MacBook" or "Personal iPhone".
- Alert before deleting the last one: If the user is about to delete their only passkey, ensure they have another active authentication method.
- Progressive onboarding: Don't force passkey creation. Suggest it after password login with a non-intrusive banner.
Migration strategy from passwords to passkeys
You can't eliminate passwords overnight. The migration must be gradual and user-centric:
Phase 1: Passkeys as an option (months 1-3)
- Add passkeys as an alternative login method
- Show post-login prompt: "Enable passkeys for faster sign-in"
- Keep password + MFA as a complete fallback
Phase 2: Passkeys as the preferred method (months 4-6)
- Enable Conditional UI — passkeys appear in the autofill dropdown
- Reduce MFA prompts for users with passkeys
- Track adoption: what percentage of users have at least one passkey
Phase 3: Gradual password deprecation (months 7-12)
- For new accounts: offer passkeys as the primary method, password as "set up later"
- Users with active passkeys: allow them to voluntarily delete their password
- Never force removal — always give the user a choice
Security comparison: passwords vs MFA vs passkeys
| Attack | Password | Password + MFA | Passkeys |
|---|---|---|---|
| Phishing | Vulnerable | Partially vulnerable | Immune |
| Credential stuffing | Vulnerable | Protected | Immune |
| Brute force | Vulnerable | Protected | Immune |
| SIM swapping | N/A | Vulnerable (SMS) | Immune |
| Man-in-the-middle | Vulnerable | Partially vulnerable | Immune (origin-bound) |
| DB breach | Hash exposure | Hash exposure | Public keys only |
Passkeys are immune to phishing because the credential is bound to the origin (domain) — a fake page on a different domain simply cannot request the credential.
Common implementation pitfalls
After working with passkeys in production, these are the most frequent mistakes I've seen:
- Not handling user cancellation: When the user cancels the biometric dialog,
startRegistration()andstartAuthentication()throw an exception. Always wrap in try/catch. - Ignoring the counter: Some authenticators don't increment the counter. Don't reject authentication for counter=0, but do log an alert if the counter decreases.
- RP ID too restrictive: If you use
app.mydomain.comas RP ID, passkeys won't work onmydomain.com. Use the root domain when possible. - No fallback offered: If the only login method is passkeys and the user is on a device without support, they're locked out.
- Forgetting the mobile experience: On iOS and Android, passkeys integrate with the operating system. Make sure to test on real devices, not just the browser emulator.
1// Proper error handling on the client
2async function handlePasskeyLogin() {
3 try {
4 const result = await loginWithPasskey();
5 if (result.verified) {
6 router.push('/dashboard');
7 }
8 } catch (error) {
9 if (error instanceof Error) {
10 if (error.name === 'NotAllowedError') {
11 // User cancelled the dialog or timeout
12 showMessage('Authentication cancelled. Please try again.');
13 } else if (error.name === 'AbortError') {
14 // Another WebAuthn ceremony interrupted this one
15 showMessage('Operation interrupted.');
16 } else {
17 showMessage('Unexpected error. Use your password as an alternative.');
18 showPasswordFallback();
19 }
20 }
21 }
22}
Enterprise adoption: Google, Apple, and Microsoft
All three major platforms have adopted passkeys natively in 2025-2026:
- Google: Passkeys as the default method for all Google accounts. Over 800 million accounts with active passkeys.
- Apple: Passkeys integrated into iCloud Keychain with automatic sync between iPhone, iPad, and Mac. Native support in Safari and native apps.
- Microsoft: Windows Hello integrated with syncable passkeys. Support in Azure AD and Microsoft Entra for enterprise authentication.
The ecosystem is mature. Major browsers support WebAuthn, operating systems sync passkeys, and server libraries (@simplewebauthn, py-webauthn, java-webauthn-server) are battle-tested in production.
Conclusions and next steps
Passkeys represent the biggest advancement in web authentication in the last two decades. They eliminate phishing, improve user experience, and reduce support tickets for forgotten passwords.
To start implementing passkeys in your application:
- Evaluate
@simplewebauthn/serverwith a local prototype - Implement passkey registration and login as an optional method
- Enable Conditional UI so passkeys appear in the autofill dropdown
- Monitor adoption and adjust your migration strategy
- Always maintain a fallback — the transition will take time
Comments
Sign in to leave a comment
No comments yet. Be the first!