hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Two-Factor and Passwordless Auth with @hectoday/http

Why Passwords Are Not Enough

  • The Problem with Passwords
  • Project Setup

TOTP (Time-Based One-Time Passwords)

  • How TOTP Works
  • Generating Secrets and QR Codes
  • Enabling 2FA on an Account
  • Verifying TOTP on Login
  • Time Windows and Clock Drift

Recovery

  • Recovery Codes
  • Disabling 2FA
  • Account Recovery When Everything Is Lost

Magic Links

  • How Magic Links Work
  • Building Magic Link Login
  • Security Considerations

WebAuthn and Passkeys

  • What Are Passkeys?
  • Registration Flow
  • Authentication Flow
  • Passkeys as Second Factor or Primary

Putting It All Together

  • Multi-Method Auth
  • Auth Method Checklist and Capstone

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?

← Authentication Flow Multi-Method Auth →

© 2026 hectoday. All rights reserved.