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.
flowchart LR
A["Generate secret\nCSPRNG"]:::store --> B["Encrypt + store\nstatus: pending"]:::idp
B --> C["otpauth:// QR"]:::client
C --> D["User confirms\nwith live code"]:::client
D --> E["status: active\nlastStep recorded"]:::rs
classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
classDef idp fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
classDef store fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
classDef rs fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614
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 withwindow: 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_REPLAYEDandRATE_LIMITEDevent with user and IP; a spike means an active relay or brute-force campaign. - Alert when one account produces many
INVALID_CODEevents 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.
Related
- Multi-Factor Authentication: TOTP and FIDO2 — the factor model, assurance levels, and where TOTP sits versus phishing-resistant FIDO2.
- Generating and storing MFA recovery codes — the fallback path when a user loses their authenticator device.
- Enforcing step-up authentication for sensitive actions — requiring a fresh TOTP check before high-risk operations.