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?