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:
- Submit email and password → get a pending session (not yet usable)
- 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?