Enabling 2FA on an Account
The two-step setup
Enabling 2FA requires two requests:
POST /me/2fa/setup— Generates a secret and returns the QR code. Does not enable 2FA yet.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?