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

Verifying TOTP on Login

The two-step login flow

Without 2FA, login is one step: submit email and password, get a session. With 2FA, login becomes two steps:

  1. Submit email and password → get a pending session (not yet usable)
  2. Submit the TOTP code → the pending session becomes a full session

The pending session cannot access protected routes. It only exists to carry the user ID between the two steps.

Updating the login route

// src/routes/auth.ts — update the login handler
import { has2FA } from "../auth.js";
import { createPendingSession } from "../sessions.js";

route.post("/login", {
  request: { body: LoginBody },
  resolve: async (c) => {
    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
    const { email, password } = c.input.body;

    const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
    if (!user || !(await bcrypt.compare(password, user.password_hash))) {
      return Response.json({ error: "Invalid credentials" }, { status: 401 });
    }

    // Check if user has 2FA enabled
    if (has2FA(user.id)) {
      // Create a pending session — not yet usable
      const pendingSessionId = createPendingSession(user.id);
      return Response.json(
        {
          requiresTwoFactor: true,
          message: "Password verified. Submit your 2FA code to POST /login/2fa.",
        },
        { headers: { "set-cookie": sessionCookie(pendingSessionId) } },
      );
    }

    // No 2FA — create a full session
    const sessionId = createSession(user.id);
    return Response.json(
      { user: { id: user.id, email: user.email, name: user.name } },
      { headers: { "set-cookie": sessionCookie(sessionId) } },
    );
  },
}),

When 2FA is enabled, the login response changes: instead of a full session, the user gets requiresTwoFactor: true and a pending session cookie.

The 2FA verification endpoint

route.post("/login/2fa", {
  resolve: async (c) => {
    const sessionId = getSessionId(c.request);
    if (!sessionId) {
      return Response.json({ error: "No pending session" }, { status: 401 });
    }

    const pendingUserId = getPendingUserId(sessionId);
    if (!pendingUserId) {
      return Response.json({ error: "No pending 2FA verification" }, { status: 401 });
    }

    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 user's TOTP secret
    const row = db.prepare("SELECT secret FROM totp_secrets WHERE user_id = ? AND enabled = 1")
      .get(pendingUserId) as { secret: string } | undefined;

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

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

    // Promote the pending session to a full session
    completePendingSession(sessionId);

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

    return Response.json({
      user: { id: user.id, email: user.email, name: user.name },
      message: "Login complete.",
    });
  },
}),

The endpoint reads the pending session from the cookie, gets the user ID, verifies the TOTP code, and promotes the session to a full session. No new cookie is needed — the same session ID is reused, but its internal state changes from pending to active.

The complete flow

# Step 1: Password
curl -c cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password123"}'
# { "requiresTwoFactor": true, "message": "..." }

# Step 2: TOTP code (from authenticator app)
curl -b cookies.txt -X POST http://localhost:3000/login/2fa \
  -H "Content-Type: application/json" \
  -d '{"code":"123456"}'
# { "user": { "id": "...", ... }, "message": "Login complete." }

# Now the session is active and can access protected routes
curl -b cookies.txt http://localhost:3000/me

What the frontend does

In a web app, the login form submits email and password. If the response includes requiresTwoFactor: true, the form shows a second field for the 6-digit code. The session cookie is already set by the first response, so the second request includes it automatically.

In a mobile app or SPA, the flow is the same: check the response, show the 2FA input, submit the code to /login/2fa.

Exercises

Exercise 1: Enable 2FA on Alice’s account (using the setup and verify endpoints). Then log in. Verify the two-step flow works.

Exercise 2: After step 1, try accessing a protected route (like GET /me). It should return 401 because the session is pending.

Exercise 3: After step 1, submit a wrong TOTP code. It should return 401. Submit the correct code. It should succeed.

Exercise 4: Log in as a user without 2FA enabled. The login should be single-step (no requiresTwoFactor in the response).

Why does the pending session use an empty userId instead of the real user ID?

← Enabling 2FA on an Account Time Windows and Clock Drift →

© 2026 hectoday. All rights reserved.