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

Enabling 2FA on an Account

The two-step setup

Enabling 2FA requires two requests:

  1. POST /me/2fa/setup — Generates a secret and returns the QR code. Does not enable 2FA yet.
  2. POST /me/2fa/verify — The user submits a TOTP code from their authenticator. If valid, 2FA is enabled.

This prevents lockouts. The user must prove they have a working authenticator before 2FA is turned on.

The setup endpoint

// src/routes/totp.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { authenticate } from "../auth.js";
import { generateSecret, generateQRCode, verifyTOTPCode } from "../totp.js";

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

      // Check if already enabled
      const existing = db
        .prepare("SELECT enabled FROM totp_secrets WHERE user_id = ?")
        .get(user.id) as { enabled: number } | undefined;
      if (existing?.enabled === 1) {
        return Response.json({ error: "2FA is already enabled" }, { status: 409 });
      }

      // Generate or regenerate secret
      const { secret, uri } = generateSecret(user.email);
      const qrCode = await generateQRCode(uri);

      // Store the secret (not yet enabled)
      if (existing) {
        db.prepare("UPDATE totp_secrets SET secret = ?, enabled = 0 WHERE user_id = ?").run(
          secret,
          user.id,
        );
      } else {
        db.prepare("INSERT INTO totp_secrets (user_id, secret, enabled) VALUES (?, ?, 0)").run(
          user.id,
          secret,
        );
      }

      return Response.json({
        secret, // For manual entry
        qrCode, // Data URL for the QR code image
        message:
          "Scan the QR code with your authenticator app, then call POST /me/2fa/verify with the code.",
      });
    },
  }),

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

      const body = await c.request.json();
      const code = body.code;

      if (!code || typeof code !== "string") {
        return Response.json({ error: "Code is required" }, { status: 400 });
      }

      // Get the stored (not yet enabled) 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) {
        return Response.json({ error: "Call POST /me/2fa/setup first" }, { status: 400 });
      }

      if (row.enabled === 1) {
        return Response.json({ error: "2FA is already enabled" }, { status: 409 });
      }

      // Verify the code
      if (!verifyTOTPCode(row.secret, code)) {
        return Response.json({ error: "Invalid code" }, { status: 400 });
      }

      // Enable 2FA
      db.prepare("UPDATE totp_secrets SET enabled = 1 WHERE user_id = ?").run(user.id);

      return Response.json({ message: "2FA is now enabled. Save your recovery codes." });
    },
  }),
]);

The flow

# Step 1: Get the QR code
curl -b cookies.txt -X POST http://localhost:3000/me/2fa/setup
# Returns: { secret: "JBSWY3...", qrCode: "data:image/png;base64,...", message: "..." }

# Step 2: Scan QR code with authenticator, get a 6-digit code

# Step 3: Verify the code
curl -b cookies.txt -X POST http://localhost:3000/me/2fa/verify \
  -H "Content-Type: application/json" \
  -d '{"code":"123456"}'
# Returns: { message: "2FA is now enabled." }

After step 3, the user’s login flow changes. They will now need to provide a TOTP code after their password. The next lesson implements this.

Security details

The secret is stored before verification. This means there is a window where the secret exists but 2FA is not enabled. This is safe because enabled = 0 means the login flow does not require TOTP. The secret is inert until enabled.

Setup can be called multiple times. If the user calls /me/2fa/setup again, the secret is regenerated. This invalidates any previous QR code. Only the latest secret works.

The verify endpoint requires authentication. The user must be logged in to enable 2FA. This prevents an attacker from enabling 2FA on someone else’s account.

Exercises

Exercise 1: Call the setup endpoint. Copy the secret from the response and add it to Google Authenticator manually (use the “enter manually” option). Verify the code matches what the QR scan would produce.

Exercise 2: Call the verify endpoint with a wrong code. It should return 400. Call it with the correct code. It should return 200.

Exercise 3: After enabling 2FA, call the setup endpoint again. It should return 409 because 2FA is already enabled.

Why does the setup endpoint not enable 2FA immediately?

← Generating Secrets and QR Codes Verifying TOTP on Login →

© 2026 hectoday. All rights reserved.