TOTP Enrollment and Verification Flow
Time-based one-time passwords are the most widely supported second factor: any authenticator app can scan a QR code and produce a rolling 6-digit code with no network dependency. TOTP does not resist a real-time phishing proxy the way a passkey does, but it is immune to SIM-swap and is the right fallback when hardware authenticators are not yet universal. This guide implements RFC 6238 enrollment and verification in Node.js and TypeScript with otplib, covering the details that separate a secure deployment from a fragile one: a bounded drift window, single-use replay protection, encrypted secret storage, and recovery codes. It is a companion to the parent MFA Enforcement Patterns guide within Secure Authentication & Session Architecture. Because the shared secret and backup codes are persisted, the data-access layer that stores them must be free of query injection — see injection attack prevention for the parameterization discipline that keeps that storage path safe.
Prerequisites
- Node.js 18+ and TypeScript with an existing authenticated-session mechanism
otplibfor TOTP andqrcodefor provisioning-URI rendering- A KMS or secrets manager providing an envelope-encryption key for secrets at rest
- A verified enrollment session (the user has already proven a factor recently)
Expected Outcomes
- A generated shared secret exposed to the user only as a QR code and manual key
- Verification that accepts a code within a bounded drift window and rejects replays
- The shared secret stored as authenticated ciphertext, never plaintext
- A set of single-use, hashed backup codes issued at enrollment
Step 1: Enroll the Authenticator — Secret and otpauth URI
Enrollment generates a random shared secret, encodes it into an otpauth:// provisioning URI, and presents that URI as a QR code. The user scans it with any authenticator app; the app and server now derive the same codes from the shared secret and the current time step.
The sequence below shows enrollment followed by the first verification that confirms the scan succeeded.
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
// 30-second step, 6 digits, SHA-1 (the interoperable RFC 6238 defaults).
authenticator.options = { step: 30, digits: 6, window: 1 };
export async function beginTotpEnrollment(userId: string, email: string) {
const secret = authenticator.generateSecret(); // base32, ~160-bit
const otpauth = authenticator.keyuri(email, 'Example App', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth);
// Hold the secret in a PENDING state until the user confirms a code.
await pendingTotp.save(userId, { secret, createdAt: Date.now() });
return { qrDataUrl, manualEntryKey: secret }; // manual key for apps without a camera
}
The secret stays in a pending, unconfirmed state until Step 2 verifies the user actually scanned it. Never activate TOTP on the account before a successful first verification, or a mistyped scan locks the user out.
Step 2: Verify with a Drift Window and Replay Guard
Verification derives the expected code from the secret and the current time step, accepting a small window on either side to tolerate clock skew. The critical addition over a naive authenticator.check() is a replay guard: a code is valid for its entire step, so you must consume each code exactly once by tracking the last accepted step counter.
import { authenticator } from 'otplib';
interface TotpState { secret: string; lastCounter: number | null; }
export function verifyTotp(token: string, state: TotpState): boolean {
// checkDelta returns the offset (-1, 0, +1) of the matching step, or null.
const delta = authenticator.checkDelta(token, state.secret);
if (delta === null) return false; // no code in the drift window
const epochStep = Math.floor(Date.now() / 1000 / 30);
const matchedCounter = epochStep + delta;
// Replay guard: reject any code at or below the last accepted step.
if (state.lastCounter !== null && matchedCounter <= state.lastCounter) {
return false;
}
state.lastCounter = matchedCounter; // persist atomically with the check
return true;
}
Persisting lastCounter must be atomic with the verification — use a conditional database update (UPDATE ... WHERE last_counter < :matched) so two concurrent requests cannot both accept the same code. Confirm the pending enrollment on the first successful verify:
export async function confirmTotpEnrollment(userId: string, token: string) {
const pending = await pendingTotp.take(userId);
if (!pending) throw new Error('No pending enrollment');
const state = { secret: pending.secret, lastCounter: null };
if (!verifyTotp(token, state)) throw new Error('Invalid code — scan again');
await activateTotp(userId, pending.secret, state.lastCounter); // encrypts + stores
return { enrolled: true };
}
Step 3: Store the Secret Encrypted and Issue Backup Codes
The shared secret is a long-lived credential: anyone holding it can generate valid codes forever. Encrypt it at rest with authenticated encryption and a key from your KMS, and never log or return it after enrollment. Issue single-use backup codes so a user who loses their device can still recover — stored hashed, exactly like passwords.
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import argon2 from 'argon2';
// AES-256-GCM: authenticated encryption. `key` comes from a KMS, not the DB.
export function encryptSecret(plain: string, key: Buffer) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv: iv.toString('base64'), tag: tag.toString('base64'), ct: ct.toString('base64') };
}
export function decryptSecret(rec: { iv: string; tag: string; ct: string }, key: Buffer) {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(rec.iv, 'base64'));
decipher.setAuthTag(Buffer.from(rec.tag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(rec.ct, 'base64')), decipher.final()]).toString('utf8');
}
// Backup codes: shown once, stored only as hashes.
export async function issueBackupCodes(count = 10): Promise<{ plain: string[]; hashes: string[] }> {
const plain = Array.from({ length: count }, () =>
randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2'));
const hashes = await Promise.all(plain.map((c) => argon2.hash(c)));
return { plain, hashes }; // return `plain` to the user ONCE
}
When a user redeems a backup code, verify it against the stored hashes and immediately delete that hash so it cannot be reused. Treat backup-code redemption as an AAL2 event and prompt the user to re-enroll a fresh factor afterward.
Verification
Confirm enrollment, drift tolerance, and replay rejection behave correctly:
# Generate a current code from a known secret and confirm the server accepts it once
node -e "const {authenticator}=require('otplib'); console.log(authenticator.generate(process.env.SECRET))"
# Run the unit suite for drift-window and replay logic
npm test -- --testPathPattern=totp
# Replay negative test: the same code must be rejected on a second submission
npm test -- --testNamePattern="rejects replayed code"
Expected: the freshly generated code verifies on first submission; the drift test accepts a code one step early or late; and the replay test confirms the second submission of an already-accepted code returns false because its step counter is no greater than lastCounter.
import { describe, it, expect } from 'vitest';
import { authenticator } from 'otplib';
import { verifyTotp } from './totp';
describe('TOTP replay protection', () => {
it('rejects a code that was already consumed', () => {
const secret = authenticator.generateSecret();
const token = authenticator.generate(secret);
const state = { secret, lastCounter: null as number | null };
expect(verifyTotp(token, state)).toBe(true); // first use accepted
expect(verifyTotp(token, state)).toBe(false); // replay rejected
});
});
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Codes rejected despite a fresh scan | Server and device clocks differ by more than the drift window | Sync the server clock via NTP; keep window: 1; do not widen the window to mask drift |
| Same code accepted twice | Replay guard missing or lastCounter not persisted atomically |
Track the matched step counter and reject values at or below it with a conditional UPDATE |
| Secret readable in the database dump | Secret stored in plaintext or encrypted with a key kept beside it | Use AES-256-GCM with a KMS-held key; store only ciphertext, IV, and tag |
| User locked out after losing device | No backup codes issued at enrollment | Issue single-use hashed backup codes during enrollment and support recovery redemption |
| Verification intermittently fails under load | Two requests race on the same code and both read a stale lastCounter |
Make the counter update a single atomic conditional write, not read-then-write |
Common Implementation Mistakes
Frequently Asked Questions
How large should the TOTP drift window be?
A window of plus or minus one 30-second step — roughly 90 seconds of total tolerance — absorbs realistic device clock skew without meaningfully widening the attack surface. Each additional step you accept extends the time a captured code remains valid, so larger windows directly increase replay opportunity. If a population of devices drifts chronically, fix it with time synchronisation guidance rather than a permanently wider window, and rely on the replay guard to bound reuse regardless of window size.
How do I prevent a TOTP code from being replayed?
A valid code remains valid for its entire time step plus the drift window, which is long enough for a real-time phishing proxy to relay it. The defence is to consume each code exactly once: record the matched time-step counter per user, and reject any verification whose counter is less than or equal to the last accepted value. Persist that update atomically with the check so concurrent requests cannot both accept the same code. This turns a code that is valid for ninety seconds into one that is valid for a single successful use.
How should the TOTP shared secret be stored?
Encrypt it at rest with authenticated encryption such as AES-256-GCM, using a key held in a KMS or secrets manager rather than in the same table as the ciphertext. The plaintext should exist only transiently in memory during enrollment and each verification. A leaked plaintext secret is catastrophic because it lets an attacker generate valid codes indefinitely without ever touching the user’s device — unlike a password, the user has no signal that it has been compromised.
Related
- MFA Enforcement Patterns — the parent guide on method selection, adaptive step-up, and session binding
- Implementing WebAuthn (FIDO2) in TypeScript — the phishing-resistant passkey alternative to app-based codes
- Secure Authentication & Session Architecture — the parent guide covering credential, session, and token controls
- Injection Attack Prevention — parameterized storage for the secret and backup-code persistence path