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

Multi-Method Auth

The real-world login page

A complete auth system offers multiple methods. The user chooses based on their preference and what they have available:

Login options:
1. Email + password (then TOTP if 2FA enabled)
2. Passkey (one-step, biometric)
3. Magic link (email)

The server supports all three. The frontend shows the options. The backend handles each independently — they all end the same way: a valid session.

Determining available methods

Create an endpoint that tells the frontend which methods are available for a user:

route.post("/auth/methods", {
  resolve: async (c) => {
    const body = await c.request.json();
    const email = (body as any).email;

    if (!email) {
      return Response.json({ error: "Email is required" }, { status: 400 });
    }

    const user = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as any;

    // Always return the same structure to avoid email enumeration
    const methods = {
      password: true, // always available
      magicLink: true, // always available
      passkey: false,
      totp: false,
    };

    if (user) {
      methods.passkey = hasPasskeys(user.id);
      methods.totp = has2FA(user.id);
    }

    return Response.json({ methods });
  },
});

[!WARNING] This endpoint reveals whether an email has passkeys or TOTP enabled. This is a mild information leak — an attacker learns which users have 2FA. If this concerns you, always return the same methods regardless of the email, and let the login flow handle the differences.

The login flow decision tree

User submits email
│
├─ Has passkeys? → Show "Sign in with passkey" option
│   └─ Passkey verified → Session created ✓
│
├─ Password submitted?
│   ├─ Has 2FA? → Show 2FA form
│   │   ├─ TOTP code → Session created ✓
│   │   ├─ Recovery code → Session created ✓
│   │   └─ Passkey as 2FA → Session created ✓
│   └─ No 2FA → Session created ✓
│
└─ Magic link requested? → Send email
    └─ Link clicked → Session created ✓

Every path leads to a session. The methods are independent — implementing one does not affect the others.

The settings page

Authenticated users manage their auth methods at /me/security:

route.get("/me/security", {
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const totpEnabled = has2FA(user.id);
    const passkeys = db
      .prepare("SELECT id, name, created_at FROM passkeys WHERE user_id = ?")
      .all(user.id);
    const unusedRecoveryCodes = db
      .prepare("SELECT COUNT(*) as count FROM recovery_codes WHERE user_id = ? AND used = 0")
      .get(user.id) as { count: number };

    return Response.json({
      totp: {
        enabled: totpEnabled,
      },
      passkeys: passkeys,
      recoveryCodes: {
        remaining: unusedRecoveryCodes.count,
      },
    });
  },
});

The response tells the frontend what to show: enable/disable TOTP, list passkeys with a “remove” button, and the number of remaining recovery codes with a “regenerate” option.

Fallback chains

When a method fails, the user needs alternatives:

TOTP code wrong? Show a link: “Use a recovery code instead.”

Lost phone (no TOTP)? “Use a recovery code” or “Sign in with a passkey” (if registered).

Lost everything? “Contact support” (admin-initiated reset from the Recovery lesson).

Passkey not available? Fall back to password login.

The fallback chain should be visible to the user. Do not make them guess that other options exist.

Exercises

Exercise 1: Implement the /auth/methods endpoint. Test with a user who has TOTP and passkeys enabled. Test with a user who has neither.

Exercise 2: Implement the /me/security endpoint. Log in and check your security settings.

Exercise 3: Set up all three methods on one account: password, TOTP, and a passkey. Log in using each method independently. All should work.

Why does the login flow offer multiple methods instead of enforcing one?

← Passkeys as Second Factor or Primary Auth Method Checklist and Capstone →

© 2026 hectoday. All rights reserved.