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

Disabling 2FA

Why disabling 2FA needs the second factor

If 2FA could be disabled with just a password, an attacker who obtains the password (phishing, breach, shoulder surfing) could disable 2FA and take over the account. The password alone is not enough — that is the entire point of 2FA.

To disable 2FA, the user must prove they have the second factor: either a valid TOTP code or a recovery code. This ensures the person disabling 2FA actually controls the authenticator, not just the password.

The disable endpoint

route.post("/me/2fa/disable", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const body = await c.request.json();
    const { code, recoveryCode } = body;

    // Get the TOTP secret
    const row = db
      .prepare("SELECT secret, enabled FROM totp_secrets WHERE user_id = ?")
      .get(user.id) as { secret: string; enabled: number } | undefined;

    if (!row || row.enabled !== 1) {
      return Response.json({ error: "2FA is not enabled" }, { status: 400 });
    }

    // Verify the second factor
    let verified = false;

    if (recoveryCode) {
      verified = await verifyRecoveryCode(user.id, recoveryCode);
    } else if (code) {
      verified = verifyTOTPCode(row.secret, code);
    }

    if (!verified) {
      return Response.json(
        { error: "Invalid code. Provide a valid TOTP code or recovery code to disable 2FA." },
        { status: 401 },
      );
    }

    // Disable 2FA
    db.prepare("DELETE FROM totp_secrets WHERE user_id = ?").run(user.id);
    db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(user.id);

    return Response.json({ message: "2FA has been disabled." });
  },
});

The endpoint requires either a TOTP code or a recovery code. On success, it deletes both the TOTP secret and all recovery codes. The user’s login flow reverts to password-only.

Why we delete rather than set enabled = 0

Setting enabled = 0 would leave the secret in the database. If the user re-enables 2FA later, we would reuse the old secret. This has two problems:

  1. The old QR code (which the user might have shared, screenshot, or not properly destroyed) would work again.
  2. If the reason for disabling was that the secret was compromised, re-enabling would reuse the compromised secret.

Deleting the secret and recovery codes forces a fresh setup if the user re-enables 2FA.

Exercises

Exercise 1: With 2FA enabled, try disabling it without any code. It should fail.

Exercise 2: Disable 2FA with a valid TOTP code. Verify the login flow is now single-step (no requiresTwoFactor).

Exercise 3: Re-enable 2FA. The setup endpoint should work (no existing secret). You should get a new QR code with a new secret.

Why must disabling 2FA require a TOTP or recovery code, not just the password?

← Recovery Codes Account Recovery When Everything Is Lost →

© 2026 hectoday. All rights reserved.