This episode turns login into two steps: a password verification that only marks pendingMfa, then a TOTP code verification that finally grants a full session. You will also learn the separation of the /login and /login/mfa endpoints and how to prevent bypassing protected routes before MFA completes.

Now that the secret is stored and 2FA is active, it's time to close the biggest remaining gap: the login flow. Episode 8 turns one-step login into two steps: verify the password first, then the 2FA challenge, and only then issue a full session.
These two steps must be truly separate and sequential. Otherwise, an attacker with the password — from credential stuffing or phishing — gets access immediately without the code. The whole value of 2FA depends on this separation, so endpoint and middleware design is the main material of this episode.
In the first step, the server checks the password. If it's valid and the user has 2FA enabled, the server doesn't immediately create a full session — it only marks a pendingMfa status on the session:
app.post('/login', async (req, res) => {
const user = await findUserByEmail(req.body.email);
if (!user || !(await bcrypt.compare(req.body.password, user.password_hash))) {
return res.status(401).json({ error: 'Kredensial salah' });
}
if (user.totp_enabled) {
req.session.pendingMfa = user.id;
return res.json({ mfaRequired: true });
}
req.session.userId = user.id;
res.json({ ok: true });
});Note that pendingMfa holds the user's identity, but it's not a substitute for authentication — it's only a sign that the password was correct and the next step is the TOTP code. Users without 2FA still go through the old flow.
The second step receives the TOTP code, verifies it against the secret, then swaps the pending status for a full session:
app.post('/login/mfa', async (req, res) => {
if (!req.session.pendingMfa) {
return res.status(403).json({ error: 'Mulai login terlebih dahulu' });
}
const user = await findUserById(req.session.pendingMfa);
const secret = decryptSecret(user.totp_secret_encrypted);
const valid = authenticator.check(req.body.token, secret);
if (!valid) {
return res.status(400).json({ error: 'Kode tidak valid' });
}
req.session.userId = user.id;
delete req.session.pendingMfa;
res.json({ ok: true });
});After the last line executes, the session only has userId without pendingMfa — this is the only condition considered a complete login.
The physical separation of POST /login and POST /login/mfa isn't just code aesthetics, it's a security control. With two endpoints, you can apply different rate limits to each (episode 10), log MFA challenge failures separately, and avoid a single handler mixing two responsibilities.
The flow structure:
password correct -> pendingMfa set -> user sees the code page
code correct -> userId set -> pendingMfa removed -> full accessIf the user closes the page mid-flow, pendingMfa expires with the session — the process restarts from the first step. This makes the flow deterministic and easy to test.
Bypass is the number one threat in this feature. An attacker could try to access a protected route with a partial session, or manipulate the cookie to look like MFA already completed. One middleware shuts down all those paths:
function requireAuth(req, res, next) {
if (!req.session.userId || req.session.pendingMfa) {
return res.status(401).json({ error: 'Autentikasi belum selesai' });
}
next();
}
app.use('/account', requireAuth);
app.use('/dashboard', requireAuth);The middleware checks two things at once: userId must exist, and pendingMfa must be gone. Attach requireAuth to every sensitive route — not just the front page. A common bad habit is securing the endpoints you can see, while forgetting account settings endpoints or internal APIs.
The pendingMfa status must be short-lived. If the user stops mid-flow, the challenge must expire automatically — for example after 5 minutes — and require starting over from the password. Limit this time in the session configuration so a leftover code can't be used hours later.
Users who haven't enabled 2FA must not pass through the MFA endpoint. The first-step handler already handles this: only when totp_enabled is true does the flow branch to the challenge. Make sure this flag is read directly from the database at login, not from client input.
If code verification fails, pendingMfa stays — the user just tries another code. But repeated failures must be constrained by rate limiting and logging, because a repeated-failure pattern could mean an attacker is trying to guess a code. Episodes 10 and 16 will test this scenario.
Episode 8 realized the two-step login flow: the password only marks pendingMfa, the TOTP code swaps it for a full session, and the requireAuth middleware blocks all access before MFA completes.
The key takeaways:
In the next episode, episode 9, we will cover backup & recovery codes — creating 10 single-use codes, storing them as hashes like passwords, marking used codes, and replacing the old batch when regenerating.