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

Project Setup

Starting point

This course builds on the Authentication with Hectoday HTTP capstone. You need signup, login, sessions, and cookies working. If you have not completed it, the project setup from that course gives you the baseline.

Additional dependencies

npm install otpauth qrcode @simplewebauthn/server
npm install -D @types/qrcode

Three new packages:

otpauth: Generates and verifies TOTP codes. Implements RFC 6238.

qrcode: Renders QR codes as data URLs (for the TOTP setup flow).

@simplewebauthn/server: Server-side WebAuthn/passkey verification. Handles the complex cryptographic verification so we do not have to.

New database tables

Add these to your db.ts:

db.exec(`
  CREATE TABLE IF NOT EXISTS totp_secrets (
    user_id TEXT PRIMARY KEY,
    secret TEXT NOT NULL,
    enabled INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (user_id) REFERENCES users(id)
  );

  CREATE TABLE IF NOT EXISTS recovery_codes (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    code_hash TEXT NOT NULL,
    used INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (user_id) REFERENCES users(id)
  );

  CREATE TABLE IF NOT EXISTS magic_links (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    token_hash TEXT NOT NULL,
    expires_at INTEGER NOT NULL,
    used INTEGER NOT NULL DEFAULT 0
  );

  CREATE TABLE IF NOT EXISTS passkeys (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    credential_id TEXT NOT NULL UNIQUE,
    public_key TEXT NOT NULL,
    counter INTEGER NOT NULL DEFAULT 0,
    name TEXT NOT NULL DEFAULT 'My Passkey',
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (user_id) REFERENCES users(id)
  );

  CREATE TABLE IF NOT EXISTS webauthn_challenges (
    user_id TEXT PRIMARY KEY,
    challenge TEXT NOT NULL,
    expires_at INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
  );
`);

What each table does:

totp_secrets: Stores the TOTP shared secret per user. enabled is 0 until the user confirms they scanned the QR code (by entering a valid code). This prevents enabling 2FA before the user has a working authenticator.

recovery_codes: Backup codes, hashed with SHA-256. used tracks whether each code has been consumed.

magic_links: Token hashes for passwordless login links. Same pattern as password reset tokens: hash before storing, single-use, time-limited.

passkeys: WebAuthn credentials. credential_id identifies the key, public_key is used for verification, counter prevents replay attacks (the counter must increase with each use).

webauthn_challenges: Temporary challenges for the WebAuthn registration and authentication flows. Stored per-user, short-lived.

Updated user type

The auth helpers need a way to check whether a user has 2FA enabled:

// src/auth.ts — add this helper
export function has2FA(userId: string): boolean {
  const row = db.prepare("SELECT enabled FROM totp_secrets WHERE user_id = ?").get(userId) as
    | { enabled: number }
    | undefined;
  return row?.enabled === 1;
}

export function hasPasskeys(userId: string): boolean {
  const row = db
    .prepare("SELECT COUNT(*) as count FROM passkeys WHERE user_id = ?")
    .get(userId) as { count: number };
  return row.count > 0;
}

Pending 2FA sessions

When a user has 2FA enabled, the login flow becomes two steps: verify password, then verify the second factor. We need a way to track the intermediate state — the user has passed the password check but has not yet provided the second factor.

Add a pendingUserId to the session store:

// src/sessions.ts — update the store type
const store = new Map<
  string,
  {
    userId: string;
    createdAt: number;
    pendingUserId?: string; // set when password verified but 2FA not yet complete
  }
>();

export function createPendingSession(userId: string): string {
  const id = crypto.randomUUID();
  store.set(id, { userId: "", createdAt: Date.now(), pendingUserId: userId });
  return id;
}

export function completePendingSession(sessionId: string): boolean {
  const session = store.get(sessionId);
  if (!session || !session.pendingUserId) return false;
  session.userId = session.pendingUserId;
  delete session.pendingUserId;
  return true;
}

export function getPendingUserId(sessionId: string): string | undefined {
  return store.get(sessionId)?.pendingUserId;
}

A pending session has a pendingUserId but an empty userId. It cannot pass the authenticate check (which requires a real userId). Only after the second factor is verified does completePendingSession promote it to a full session.

[!NOTE] This is a deliberate design choice. The pending session cannot be used to access protected routes. It only exists to carry the user ID between the password step and the 2FA step. If the user abandons the flow, the pending session expires naturally.

File structure

src/
  app.ts
  server.ts
  db.ts              # updated with new tables
  auth.ts            # updated with has2FA, hasPasskeys
  sessions.ts        # updated with pending sessions
  cookies.ts
  routes/
    auth.ts          # login (updated for 2FA flow)
    totp.ts          # 2FA setup, verify, disable
    recovery.ts      # recovery code generation and use
    magic-link.ts    # passwordless email login
    passkeys.ts      # WebAuthn registration and authentication

Verify it works

npm run dev

# Login still works as before
curl -c cookies.txt -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password123"}'

In the next lesson, we add TOTP.

Exercises

Exercise 1: Run the app and verify the new tables were created. Check with sqlite3 app.db ".tables".

Exercise 2: Look at the createPendingSession function. Why does it set userId to an empty string instead of the actual user ID? (Answer: so the session cannot pass the authenticate check, which requires a non-empty userId.)

Why is the TOTP secret stored with an 'enabled' flag instead of just storing it when 2FA is turned on?

← The Problem with Passwords How TOTP Works →

© 2026 hectoday. All rights reserved.