WebAuthn and Passkeys: A Complete Hands-On Guide to Passwordless Authentication in the Post-Password Era
Passwords Are Dead, Long Live Passkeys
The fundamental problem with passwords: passwords are shared secrets — both the server and the user know them. If either side is compromised, security fails.
| Problem | Impact |
|---|---|
| Weak passwords | 81% of data breaches stem from weak passwords |
| Password reuse | One leak compromises all accounts |
| Phishing attacks | Users cannot distinguish real from fake sites |
| Database breaches | Stored password hashes can be cracked |
| MFA SMS hijacking | SIM swap attacks |
Passkeys (WebAuthn/FIDO2) replace passwords with public-key cryptography:
Password model: User → [password] → Server (stores password hash)
Passkey model: User → [signature] → Server (stores only public key)
A public key cannot derive the private key. Even if the server is breached, attackers cannot impersonate users.
WebAuthn Protocol Flow
Registration Flow
1. User clicks "Register Passkey"
2. Server generates challenge → sends to frontend
3. Browser invokes authenticator → user verifies (fingerprint/Face ID/PIN)
4. Authenticator generates key pair → returns public key + signature
5. Frontend sends public key + signature to server
6. Server verifies signature → stores public key (credential)
Authentication Flow
1. User clicks "Sign in with Passkey"
2. Server generates challenge → sends to frontend
3. Browser invokes authenticator → user verifies
4. Authenticator signs challenge with private key → returns signature
5. Frontend sends signature to server
6. Server verifies signature with public key → authentication successful
Complete Code Implementation
Server: Registration Start
// POST /api/webauthn/register/start
import { generateRegistrationOptions } from '@simplewebauthn/server';
export async function registerStart(userId: string, username: string) {
const options = await generateRegistrationOptions({
rpID: 'example.com',
rpName: 'ToolsKu',
userID: userId,
userName: username,
attestationType: 'none',
authenticatorSelection: {
authenticatorAttachment: 'platform', // Prefer platform authenticator
residentKey: 'required', // Discoverable credential (Passkey)
userVerification: 'preferred',
},
});
// Store challenge for later verification
await storeChallenge(userId, options.challenge);
return options;
}
Frontend: Registration
import { startRegistration } from '@simplewebauthn/browser';
async function registerPasskey() {
try {
// 1. Get registration options from server
const options = await fetch('/api/webauthn/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id, username: user.name }),
}).then(r => r.json());
// 2. Call browser API to register
const credential = await startRegistration({ optionsJSON: options });
// 3. Send credential to server for verification
const result = await fetch('/api/webauthn/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id, credential }),
}).then(r => r.json());
if (result.verified) {
showToast('Passkey registered successfully!');
}
} catch (error) {
if (error.name === 'NotAllowedError') {
showToast('User cancelled the operation');
}
}
}
Server: Registration Completion
import { verifyRegistrationResponse } from '@simplewebauthn/server';
export async function registerFinish(userId: string, credential: any) {
const challenge = await getChallenge(userId);
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
});
if (verification.verified) {
// Store credential (public key)
await storeCredential(userId, {
id: verification.registrationInfo?.credentialID,
publicKey: verification.registrationInfo?.credentialPublicKey,
counter: verification.registrationInfo?.counter,
deviceType: verification.registrationInfo?.credentialDeviceType,
});
}
return { verified: verification.verified };
}
Frontend: Authentication
import { startAuthentication } from '@simplewebauthn/browser';
async function authenticateWithPasskey() {
try {
// 1. Get authentication options
const options = await fetch('/api/webauthn/auth/start', {
method: 'POST',
}).then(r => r.json());
// 2. Call browser API to authenticate
const credential = await startAuthentication({ optionsJSON: options });
// 3. Verify signature
const result = await fetch('/api/webauthn/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
}).then(r => r.json());
if (result.verified) {
redirect('/dashboard');
}
} catch (error) {
if (error.name === 'NotAllowedError') {
showToast('Authentication cancelled');
}
}
}
Platform Authenticators vs Roaming Authenticators
| Dimension | Platform Authenticator | Roaming Authenticator |
|---|---|---|
| Examples | Touch ID, Face ID, Windows Hello | YubiKey, Titan Key |
| Storage | In-device TPM/Secure Enclave | Hardware security chip |
| Cross-device | iCloud/Google sync | Must carry physically |
| Cost | Free | $25-70 |
| User Experience | Fingerprint/face, seamless | Insert/touch |
| Loss Risk | Device loss | Key loss |
Passkey Sync Mechanisms
Apple iCloud Keychain:
iPhone registers Passkey → iCloud sync → Mac/iPad auto-available
Google Password Manager:
Android registers Passkey → Google sync → Chrome all-platform available
Windows Hello:
Local TPM storage → No cross-device sync
→ Recommend registering both platform + roaming Passkey
Fallback Strategy: Progressive Deployment
function AuthForm() {
const [supportsPasskey, setSupportsPasskey] = useState(false);
useEffect(() => {
// Detect WebAuthn support
setSupportsPasskey(
window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === 'function'
);
}, []);
return (
<form>
{supportsPasskey && (
<Button onClick={authenticateWithPasskey}>
Sign in with Passkey
</Button>
)}
{/* Traditional password as fallback */}
<Input type="email" placeholder="Email" />
<Input type="password" placeholder="Password" />
<Button type="submit">Sign in</Button>
</form>
);
}
Conditional UI: Auto-Detect Passkey
// Check if user has a registered Passkey
const isConditionalAvailable = await PublicKeyCredential.isConditionalMediationAvailable();
if (isConditionalAvailable) {
// Browser auto-shows Passkey option in password field
const credential = await startAuthentication({
optionsJSON: options,
useAutocomplete: true, // Enable conditional UI
});
}
<!-- Password field auto-shows Passkey option -->
<input
type="password"
autocomplete="webauthn"
placeholder="Password or Passkey"
/>
Security Considerations
1. Challenge Security
// ✅ Challenge must be a server-generated random number
const challenge = crypto.randomUUID();
// ❌ Never use fixed values or timestamps
const badChallenge = Date.now().toString();
2. Origin Verification
// ✅ Strictly verify Origin to prevent phishing
const expectedOrigin = 'https://example.com';
// ❌ Don't use wildcards
const badOrigin = '*';
3. Credential Counter
// Detect cloned credentials
if (newCounter <= storedCounter) {
// Credential may have been cloned!
throw new Error('Possible cloned authenticator');
}
4. Multi-Credential Strategy
Recommendation: Register at least 2 Passkeys per user
- 1 platform Passkey (daily use)
- 1 roaming Passkey or backup Passkey (for recovery)
Browser and Platform Support
| Platform | Passkey Support | Sync | Conditional UI |
|---|---|---|---|
| Chrome (Windows/macOS) | ✅ | ✅ | |
| Safari (macOS/iOS) | ✅ | iCloud | ✅ |
| Firefox | ✅ | None | ❌ |
| Edge | ✅ | ✅ | |
| Android Chrome | ✅ | ✅ | |
| iOS Safari | ✅ | iCloud | ✅ |
Summary
Passkeys are the terminator of passwords — based on public-key cryptography, phishing-resistant, leak-resistant, and replay-resistant. By 2026, all mainstream browsers and operating systems support them. It's time to deploy in production. Use the @simplewebauthn library for quick integration, and combine with conditional UI for seamless user switching. Remember the fallback strategy: Passkey first, password as fallback, progressive enhancement.
Try these browser-local tools — no sign-up required →