Learn 2FA Authentication - First Code Verification (Enrollment Confirm)
Episode 6 of 23

Learn 2FA Authentication - First Code Verification (Enrollment Confirm)

This episode closes the enrollment flow: the user enters 6 digits after scanning the QR, the server verifies them with authenticator check and a window tolerance, and only then enables totpEnabled. You will also see when the recovery codes are first displayed.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

The Enable 2FA page already shows the QR, and the user has scanned it. But 2FA isn't active yet — the first code hasn't been verified. Episode 6 closes the enrollment flow with first-code verification: the user types the 6 digits shown in Google Authenticator, and the server confirms the code truly matches the secret encoded in the URI.

Why must this step never be skipped? Because the first verification proves three things at once: the QR was read correctly, the secret reached the user's app intact, and the clocks on both sides are synchronized well enough. Only then is it safe to enable totpEnabled and show the recovery codes.

The Enrollment Confirm Flow

Scan, Input, and Verify

After scanning, the user opens Google Authenticator, sees the 6 digits, and enters them into the confirmation form. The server receives the code and compares it against the temporarily stored secret:

JSEnrollment confirmation endpoint
const { authenticator } = require('otplib');
 
app.post('/account/enable-2fa/confirm', (req, res) => {
  const { token, secret } = req.body;
  const valid = authenticator.check(token, secret);
  if (!valid) {
    return res.status(400).json({ error: 'Kode tidak valid' });
  }
  encryptAndSaveSecret(req.user.id, secret);
  enableTotp(req.user.id);
  res.json({ ok: true });
});

The authenticator.check method returns a boolean: true if the code matches the secret within the allowed time window, false otherwise. After true, the secret is encrypted and persisted permanently, then totpEnabled is enabled.

Ideally, both operations — saving the secret and enabling the flag — are wrapped in a single database transaction so there's no state where the secret is stored but 2FA isn't active yet.

Time Window Tolerance

A TOTP code is only valid within a 30-second window. Because server and device clocks can differ by a few seconds, otplib offers tolerance through the window option. A value of 1 means the server accepts the code for the current step, one step before, and one step after:

Window tolerance of 1
[step-1] [current step] [step+1]
  30s       30s           30s        <- 6 digits each

otplib's default window is 0. Set it to 1 in production to absorb small clock differences without sacrificing security: window: 1. If a code is valid sometimes and invalid other times, don't keep raising the window — check clock synchronization via NTP as in episode 2.

The term step refers to one 30-second window. The current step, the previous step, and the next step form three candidate codes considered valid — otplib compares them all when window is 1. A window value larger than 2 is almost never justified, because it means the server accepts codes that are already tens of seconds stale.

Warning

Every failed verification should be logged. Three to five consecutive failed attempts at the MFA step usually indicate a brute-force attack or a user who lost their device — enable a cooldown or require re-verification. The rate limiting mechanism is covered in full in episode 10.

Enabling totpEnabled

Only After the Code Validates

The security key of this flow: totpEnabled only becomes true inside the same handler after authenticator.check returns true. Never provide a separate endpoint that enables 2FA without verification — that opens a loophole where a user (or an attacker with a session) can enable 2FA to their own device without proof of secret ownership.

Then Show the Recovery Codes

After a successful activation, this is the right moment to show the recovery codes for the first time. The recovery codes must be displayed once and the user asked to save them before continuing — because once 2FA is active, losing the authenticator without a recovery code means being locked out of the account. The generation and management of recovery codes is dissected in full in episode 9.

Repeated Failures and Recovery

Cooldown and Lockout

The 6-digit code has a small search space — only a million possibilities — so repeated failures must be met with a cooldown. A common strategy: limit attempts per minute, then add an exponential delay after several failures. If the user can't get in at all, point them to the recovery code flow covered in episode 9.

A good cooldown also tracks per-account and per-IP separately, so a distributed attack still shows up as a pattern from one point of view.

Helpful Error Messages

Error messages must not leak account status. "Kode tidak valid" (invalid code) is safer than a message that mentions 2FA, because an attacker can't tell whether an account has 2FA enabled or not. Give clock synchronization hints only on pages that genuinely require a TOTP code.

Also hide the window details used — an attacker doesn't need to know how many stale codes the server still accepts.

When to Delete the Pending Secret

The secret stored temporarily during enrollment also needs a lifespan. If the user never completes the first verification within a certain time, delete the pending secret and show a fresh enrollment page. This keeps the database clean and prevents stale QRs from remaining valid forever.

Deletion should also revoke all pending tokens already issued for that enrollment session, so a page still open in the browser can't complete verification with a stale secret.

Calibrating Your Mindset: When a Code Is Rejected

For clarity, here are the situations that make the first verification fail:

  • The code is mistyped or the 6 digits don't match the app.
  • The user scanned an expired QR from a previous enrollment.
  • The secret sent by the form differs from the one rendered in the QR.
  • The device clock is so far from the server time that it exceeds the window.

For the last three cases, the right fix is to restart enrollment with a new secret, not to raise the window tolerance. A new secret counts as a rotation and is allowed as long as 2FA isn't active yet.

Conversely, a failure from a mistyped code can simply be left without side effects — the user just tries again on the next step.

Conclusion

Episode 6 closed the enrollment flow with the first verification: the user types the 6 digits, the server checks them with authenticator.check and a window tolerance, and only then enables totpEnabled and shows the recovery codes.

The key takeaways:

  • authenticator.check verifies the code against the secret in a timing-safe way.
  • window 1 accepts codes one step before and after the current step.
  • totpEnabled only becomes active after the first code validates.
  • Don't separate enabling 2FA from code verification.
  • Recovery codes are first shown right after activation.
  • Repeatedly failing codes mean restarting enrollment, not raising the window.

In the next episode, episode 7, we will cover storing the secret securely — encrypting the Base32 secret with AES-256-GCM, managing the key via the MFA_ENCRYPTION_KEY environment variable, and logging practices that never leak a secret or a code.

Learn 2FA Authentication - First Code Verification (Enrollment Confirm) | Learn 2FA Authentication