Passkeys as Second Factor or Primary
Two ways to use passkeys
Passkeys can serve two different roles in your auth system:
As a second factor (2FA): The user logs in with their password, then uses their passkey instead of a TOTP code. The passkey replaces TOTP, not the password. Security: two factors (password + passkey).
As the primary method (passwordless): The user logs in with just their passkey. No password needed. The passkey is the only factor. Security: one factor, but a very strong one (phishing-resistant, cryptographic).
Passkeys as a second factor
Update the 2FA login flow to accept passkeys alongside TOTP:
// In /login/2fa — extend to accept passkeys
route.post("/login/2fa", {
resolve: async (c) => {
const sessionId = getSessionId(c.request);
if (!sessionId) return Response.json({ error: "No pending session" }, { status: 401 });
const pendingUserId = getPendingUserId(sessionId);
if (!pendingUserId) return Response.json({ error: "No pending 2FA" }, { status: 401 });
const body = await c.request.json();
// Option 1: TOTP code
if (body.code) {
// ... existing TOTP verification ...
}
// Option 2: Recovery code
if (body.recoveryCode) {
// ... existing recovery code verification ...
}
// Option 3: Passkey assertion
if (body.passkeyResponse) {
// Verify the passkey response against the pending user's credentials
const passkey = db
.prepare("SELECT * FROM passkeys WHERE credential_id = ? AND user_id = ?")
.get(body.passkeyResponse.id, pendingUserId) as any;
if (!passkey) {
return Response.json({ error: "Unknown credential" }, { status: 401 });
}
// ... verify the assertion with @simplewebauthn/server ...
// On success: completePendingSession(sessionId)
}
return Response.json(
{ error: "Provide code, recoveryCode, or passkeyResponse" },
{ status: 400 },
);
},
}); When used as a second factor, the passkey is a replacement for TOTP — the user still enters their password first. This is stronger than TOTP because passkeys are phishing-resistant (the browser checks the origin) while TOTP codes can be phished (the attacker’s fake site shows a “enter your code” field and relays it to the real site in real time).
Passkeys as the primary method
The authentication flow from the previous lesson already implements passwordless login: the user authenticates with just their passkey, and a session is created. No password step.
To support this in your app, add passkeys as a login option on the login page:
┌──────────────────────────────┐
│ Log in │
│ │
│ [Email] │
│ [Password] │
│ [Log in] │
│ │
│ ── or ── │
│ │
│ [Sign in with passkey 🔑] │
│ [Send me a magic link 📧] │
└──────────────────────────────┘ The passkey button calls POST /auth/passkey/options and then navigator.credentials.get(). If the user has a passkey for this site, the browser prompts for biometric authentication and completes the login.
Which approach to choose
Password + passkey (2FA): Best for apps transitioning from password-only auth. Users keep their passwords and add passkeys as a second factor. Strongest option (two factors, one phishing-resistant).
Passkey-only (passwordless): Best for new apps or apps with tech-savvy users. Simpler UX (one step to log in). Strong security (phishing-resistant) but single-factor.
Password + passkey (2FA) with passkey-only option: The most flexible. Users can choose their security level. Offer passkey-only login for users who prefer convenience, and password + passkey for users who want maximum security.
Platform authenticators vs. security keys
Platform authenticators (Touch ID, Windows Hello, Android biometric): convenient because they are built in. The user does not need to carry a separate device. But if the device is lost, the passkey is gone (unless synced via iCloud or Google).
Security keys (YubiKey, Titan Key): work across devices (plug into USB, tap NFC). More resilient to device loss (the key is a separate physical object). But the user must carry the key.
Synced passkeys (iCloud Keychain, Google Password Manager): combine the convenience of platform authenticators with cross-device availability. A passkey created on iPhone syncs to Mac and iPad. This is the default behavior on modern devices and is what most users will experience.
Exercises
Exercise 1: Register a passkey, then log in with it (passwordless, no password). Verify the session is created.
Exercise 2: Register a passkey and enable TOTP 2FA. Log in with password, then use the passkey as the second factor instead of a TOTP code.
Exercise 3: List your registered passkeys: GET /me/passkeys. What information is stored? (Credential ID, name, creation date. Never the private key.)
Why are passkeys more phishing-resistant than TOTP when used as a second factor?