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

Recovery Codes

When the phone is gone

The user enabled 2FA with their phone. Then their phone is lost, stolen, broken, or factory-reset. The authenticator app and its secrets are gone. The user cannot generate TOTP codes. Without a backup, they are locked out of their account.

Recovery codes solve this. At 2FA setup, the app generates a set of one-time-use codes that the user saves offline (printed, written down, or stored in a password manager). Each code works exactly once, in place of a TOTP code.

Generating recovery codes

// src/recovery.ts
import db from "./db.js";

function generateCode(): string {
  // 8 alphanumeric characters, grouped for readability
  const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
  let code = "";
  const bytes = new Uint8Array(8);
  crypto.getRandomValues(bytes);
  for (const b of bytes) {
    code += chars[b % chars.length];
  }
  return code.slice(0, 4) + "-" + code.slice(4);
}

async function hashCode(code: string): Promise<string> {
  const normalized = code.replace(/-/g, "").toLowerCase();
  const data = new TextEncoder().encode(normalized);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function generateRecoveryCodes(userId: string): Promise<string[]> {
  // Delete any existing codes
  db.prepare("DELETE FROM recovery_codes WHERE user_id = ?").run(userId);

  const codes: string[] = [];
  for (let i = 0; i < 10; i++) {
    const code = generateCode();
    const codeHash = await hashCode(code);
    const id = crypto.randomUUID();
    db.prepare("INSERT INTO recovery_codes (id, user_id, code_hash, used) VALUES (?, ?, ?, 0)").run(
      id,
      userId,
      codeHash,
    );
    codes.push(code);
  }

  return codes; // Return plain-text codes to show the user ONCE
}

export async function verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
  const codeHash = await hashCode(code);
  const row = db
    .prepare("SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used = 0")
    .get(userId, codeHash) as { id: string } | undefined;

  if (!row) return false;

  // Mark as used (single-use)
  db.prepare("UPDATE recovery_codes SET used = 1 WHERE id = ?").run(row.id);
  return true;
}

Key design choices:

10 codes. This is the industry standard (Google, GitHub, Stripe). Enough for occasional use, not so many that the user ignores them.

Hashed storage. Like passwords and API keys, recovery codes are hashed before storage. If the database is breached, the codes are unusable.

Single-use. After a code is used, it is marked used = 1 and cannot be used again. This limits the damage if a code is compromised.

Normalized before hashing. The code is lowercased and dashes are removed before hashing. This means ab3f-k2m9 and AB3FK2M9 both work.

Showing codes at 2FA setup

Update the 2FA verify endpoint to generate recovery codes when 2FA is enabled:

// In the /me/2fa/verify handler, after enabling 2FA:
db.prepare("UPDATE totp_secrets SET enabled = 1 WHERE user_id = ?").run(user.id);

const recoveryCodes = await generateRecoveryCodes(user.id);

return Response.json({
  message: "2FA is now enabled. Save your recovery codes — they will not be shown again.",
  recoveryCodes,
});

The response includes the recovery codes in plain text. The user must save them now. They cannot be retrieved later (only the hashes are stored).

[!WARNING] Tell the user clearly: “Save these codes now. They will not be shown again.” Display them in a copy-friendly format. Some apps offer a “Download as text file” option.

Using a recovery code at login

Update the /login/2fa endpoint to accept either a TOTP code or a recovery code:

route.post("/login/2fa", {
  resolve: async (c) => {
    // ... get pending session and user ID ...

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

    if (recoveryCode) {
      // Try recovery code
      const valid = await verifyRecoveryCode(pendingUserId, recoveryCode);
      if (!valid) {
        return Response.json({ error: "Invalid recovery code" }, { status: 401 });
      }
    } else if (code) {
      // Try TOTP code
      const row = db
        .prepare("SELECT secret FROM totp_secrets WHERE user_id = ? AND enabled = 1")
        .get(pendingUserId) as { secret: string } | undefined;
      if (!row || !verifyTOTPCode(row.secret, code)) {
        return Response.json({ error: "Invalid 2FA code" }, { status: 401 });
      }
    } else {
      return Response.json({ error: "Provide code or recoveryCode" }, { status: 400 });
    }

    completePendingSession(sessionId);
    // ... return user ...
  },
});

The user submits either { "code": "123456" } (TOTP) or { "recoveryCode": "ab3f-k2m9" } (recovery). Both paths complete the pending session.

Exercises

Exercise 1: Enable 2FA and save the recovery codes. Log out. Log in with password, then use a recovery code instead of a TOTP code. Verify it works.

Exercise 2: Use the same recovery code again. It should fail because it was marked as used.

Exercise 3: Check how many unused recovery codes remain. Write a query: SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used = 0.

Why are recovery codes hashed before storage?

← Time Windows and Clock Drift Disabling 2FA →

© 2026 hectoday. All rights reserved.