Implementing TOTP Enrollment and Verification in Node

You need to add an authenticator-app second factor and want a TOTP implementation that survives a code review — correct secret generation, a tight verification window, replay protection, and an encrypted secret column. This guide is part of the Multi-Factor Authentication: TOTP and FIDO2 guide and covers the full enrollment-to-verification path in TypeScript with otplib.

Why the Spec Forces These Decisions

TOTP (RFC 6238) computes a one-time code from a shared secret and the current time: the time-step counter is C = floor(unixTime / 30), and the code is the truncated HMAC-SHA1(secret, C). Two consequences of that definition drive the entire implementation.

First, because the code depends only on (secret, time), the same code is valid for the entire 30-second step and is replayable until the step rolls over — unless the server remembers which step it last accepted. RFC 6238 §5.2 explicitly says the verifier should reject a code if it was already accepted in the current or a prior step. Second, because the secret alone lets anyone forge codes forever, the secret is a credential equivalent to a password and must never sit in plaintext in your database.

A correct flow therefore has a pending enrollment that the user must confirm with a live code before it becomes active, plus a per-user record of the last accepted time-step.

TOTP enrolment states from pending to active A secret is generated, encrypted and stored in a pending state, presented as a provisioning URI, and only promoted to active once the user submits a code derived from it, at which point the consumed time step is recorded. Generate secret 160-bit CSPRNG Store encrypted state: pending Show QR otpauth:// URI Active after a live code A pending secret that never activates must expire — otherwise abandoned enrolments accumulate as live credentials.
The pending state is what prevents the classic lockout: a secret the server believes in but the user's app never received.

Step 1 — Pin otplib Parameters Explicitly

Do not trust library defaults for security parameters; pin them so an upgrade cannot change behavior silently.

import { authenticator } from "otplib";

authenticator.options = {
  algorithm: "sha1", // interoperable default for authenticator apps (RFC 6238)
  digits: 6,
  step: 30, // 30-second time step
  window: 1, // tolerate only ±1 step of clock skew
};

window: 1 accepts the previous, current, and next step — a ±30s tolerance. Resist the urge to widen it; clock skew is an NTP problem, not a TOTP parameter.

Step 2 — Generate and Encrypt the Secret at Rest

Generate a 160-bit secret and encrypt it before it touches the database. Use AES-256-GCM with a key from your KMS or secrets manager — never a constant in source.

import { authenticator } from "otplib";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

const KEY = Buffer.from(process.env.MFA_ENC_KEY!, "base64"); // 32 bytes from KMS

export function encryptSecret(plaintext: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", KEY, iv);
  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  // Store iv:tag:ciphertext, all base64.
  return [iv, tag, ct].map((b) => b.toString("base64")).join(":");
}

export function decryptSecret(stored: string): string {
  const [iv, tag, ct] = stored.split(":").map((s) => Buffer.from(s, "base64"));
  const decipher = createDecipheriv("aes-256-gcm", KEY, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
}

export function newSecret(): string {
  return authenticator.generateSecret(20); // 20 bytes -> 160-bit base32
}

Step 3 — Begin Enrollment: Issue the otpauth URI and QR

Persist the encrypted secret as pending and hand the browser a QR image, not the raw secret.

import { authenticator } from "otplib";
import { toDataURL } from "qrcode";

export async function beginEnrollment(userId: string, email: string) {
  const secret = newSecret();
  await db.mfa.upsert({
    userId,
    secretEnc: encryptSecret(secret),
    status: "pending",
    lastStep: null,
  });

  const otpauth = authenticator.keyuri(email, "Acme Identity", secret);
  const qrDataUrl = await toDataURL(otpauth); // data:image/png;base64,...
  return { qrDataUrl }; // never return `secret` itself to the client UI logic
}

The keyuri output encodes algorithm, digits, and period, so a scanning app inherits your pinned parameters.

Step 4 — Confirm Enrollment With a Live Code

Activate only after the user proves possession by entering a current code. This is also where replay protection is first applied.

import { authenticator } from "otplib";

// Current time-step index, used for replay tracking.
function currentStep(): number {
  return Math.floor(Date.now() / 1000 / 30);
}

export async function confirmEnrollment(userId: string, token: string) {
  const row = await db.mfa.find({ userId, status: "pending" });
  if (!row) throw new Error("NO_PENDING_ENROLLMENT");

  const secret = decryptSecret(row.secretEnc);
  if (!authenticator.verify({ token, secret })) {
    throw new Error("INVALID_CODE");
  }

  await db.mfa.update(userId, { status: "active", lastStep: currentStep() });
  return { activated: true };
}

Step 5 — Verify at Sign-In With Replay Protection

The verification path on every subsequent login must do two things: verify the code, then reject it if its time-step was already consumed.

import { authenticator } from "otplib";

export async function verifyAtLogin(userId: string, token: string): Promise<boolean> {
  const row = await db.mfa.find({ userId, status: "active" });
  if (!row) return false;

  // Rate limit BEFORE crypto to blunt brute force (6 digits = 1e6 space).
  if (await isRateLimited(userId)) throw new Error("RATE_LIMITED");

  const secret = decryptSecret(row.secretEnc);
  if (!authenticator.verify({ token, secret })) {
    await recordFailure(userId);
    return false;
  }

  // Replay protection: the accepted code's step must be newer than the last one.
  const step = currentStep();
  if (row.lastStep !== null && step <= row.lastStep) {
    throw new Error("CODE_REPLAYED");
  }
  await db.mfa.update(userId, { lastStep: step });
  return true;
}

The step <= row.lastStep check is the heart of RFC 6238 §5.2 replay resistance: once a code from step N is accepted, no code from step N or earlier is ever accepted again, even though it remains cryptographically valid until the window closes.

Security Implications

  • Replay window: without lastStep, a single phished or shoulder-surfed code is reusable for up to 90 seconds with window: 1. The step check closes it to the granularity of one step.
  • Secret disclosure: an unencrypted secret column turns one database leak into permanent code forgery for every user. AES-256-GCM with a KMS key contains the blast radius to a single key compromise.
  • Brute force: a 6-digit code has only 10⁶ values. Rate limiting and lockout (OWASP ASVS V2.2.1) are mandatory; place the check before decryption so attackers cannot even force the crypto path.
  • Enforcement gaps: verify TOTP on every session-minting path, not just the password page — including “trust this device” and password-reset re-authentication.

Prevention & Monitoring Hooks

  • Log every CODE_REPLAYED and RATE_LIMITED event with user and IP; a spike means an active relay or brute-force campaign.
  • Alert when one account produces many INVALID_CODE events across IPs (credential-stuffing follow-on).
  • Track MFA enrollment coverage as a metric; un-enrolled privileged accounts are your weakest link.
  • Emit an audit event on enrollment activation and on secret rotation so recovery and step-up flows can correlate.

Frequently Asked Questions

Why use window: 1 instead of a larger window?

Each extra step you accept lengthens the period during which a captured code stays valid, directly widening the phishing and replay window. window: 1 already tolerates ±30s of clock skew, which is more than a correctly NTP-synced server should ever see. If users routinely fail with window: 1, fix the server clock with NTP or chrony rather than widening the tolerance.

Where do I store the AES key for the encrypted secret?

In a KMS or secrets manager (AWS KMS, GCP KMS, HashiCorp Vault), injected at runtime as MFA_ENC_KEY. The point of encrypting the secret is to defend against a database leak, so the key must live somewhere a database dump does not expose. A constant in source code provides no protection — anyone with the dump and the repo can decrypt every secret.

Does last-used-step tracking break legitimate retries?

No. A user who mistypes and retries within the same 30 seconds simply submits the same valid code again — and that resubmission is exactly what you want to reject as a replay only after one has already succeeded. Until a code is accepted, lastStep is unchanged, so a corrected entry in the next step verifies normally. The check only blocks reusing a code whose step has already been consumed by a successful verification.

SHA1 sounds weak — should I use SHA-256 for TOTP?

The SHA-1 here is inside HMAC, where collision resistance is not the relevant property, so HMAC-SHA1 remains secure for TOTP per RFC 6238. The practical issue is interoperability: most authenticator apps assume SHA-1, 6 digits, and a 30-second period. If you set algorithm: "sha256", the otpauth:// URI must advertise it and the app must honor it — many do not, and you will get mismatched codes. Stick with SHA-1 unless you control every client.

How do recovery codes fit with TOTP?

TOTP depends on the user’s device; if they lose it, they are locked out unless you provide a fallback. Single-use recovery codes are that fallback, but they must be CSPRNG-generated, hashed like passwords, and invalidated on use — see the recovery codes guide. Treat a recovery-code login as lower assurance and consider prompting re-enrollment afterward.

Verification Windows and Replay

Two numbers decide whether verification is both usable and safe: how much clock skew you tolerate, and whether a code can be presented twice.

Acceptance window around the current time step Accepting one step either side of the current 30-second window tolerates ordinary device drift while keeping only three codes valid at any moment; wider windows multiply the number of simultaneously valid codes. 30-second time steps t − 2 t − 1 · accepted t · accepted t + 1 · accepted t + 2 Record the consumed step per user Reject any submission whose step is not strictly greater — otherwise one observed code works for a full minute.
Three valid codes at a time is the usable maximum. Every extra step you accept doubles down on the same trade in the attacker's favour.

Rate limiting completes the picture. With a six-digit code and a three-step window, an unlimited attacker has better odds than intuition suggests, so cap verification attempts per account — five is generous — and lock the factor with a notification after that. Count failures against the account rather than the source address, because addresses rotate and accounts do not.

Protecting the Shared Secret at Rest

Envelope encryption for stored TOTP secrets Each secret is encrypted with a data key that is itself wrapped by a key-management service key, so a database dump alone cannot produce working codes and every decryption is logged. TOTP secret symmetric, reusable Encrypted with a data key stored in the row Data key wrapped by KMS every unwrap is logged Hashing is not an option here — verification needs the original value.
A leaked table of TOTP secrets is a permanent bypass for every enrolled account, which is why the key belongs outside the database.