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?