Design a TOTP verifier
Design the server side of authenticator-app codes: RFC 4226 HOTP with HMAC-SHA1 dynamic truncation, RFC 6238 time steps, a bounded skew window, replay prevention by remembering the last used step, secrets sealed with envelope encryption, lockout that actually bounds guessing, and constant-time comparison.
01Understand the problem
- Compute HOTP from first principles: HMAC-SHA1 over an 8-byte counter, then dynamic truncation
- Turn wall-clock time into a time step and accept a bounded skew window
- Stop replay by storing the last accepted step and advancing it with compare-and-set
- Size rate limiting from the actual guessing maths, not a gut feeling
What they probe: whether you can explain dynamic truncation, why the server must hold the secret in recoverable form (unlike a password), what stops a code being used twice inside its 30 seconds, and how many guesses your lockout really allows per month. Where candidates slip: string comparison with ===, accepting the same code twice, a skew window of five steps 'to be safe', secrets stored in plain text next to the password hash.
TOTP (time-based one-time password, RFC 6238) is what Google Authenticator, 1Password and friends show. It is HOTP (HMAC-based one-time password, RFC 4226) with the counter replaced by the number of 30-second periods since the Unix epoch. Both sides share a secret; both compute the same 6 digits; nothing travels except the digits.
enrol(userId)creates a 160-bit secret, seals it, and returns anotpauth://URI for a QR code. The factor stays unconfirmed until the user proves one code.verify(userId, code)returns ok or a typed reason:malformed,not_enrolled,throttled,invalid,replayed.- Accept the current step and at most one step either side (configurable 0 to 2).
- A code (more precisely, a step) is accepted at most once per user, even under concurrent requests.
- Failed attempts lock the factor with exponential back-off.
| Number | Value | Why it matters |
|---|---|---|
| Valid codes per guess | 3 in 10^6 (skew of 1 step) | Each extra skew step adds two more valid codes |
| Expected guesses to win, unthrottled | about 333,000 | At 100 requests per second that is under an hour |
| 5 tries per 15 min, fixed | 480 per day, about 4.2% success in 30 days | Fixed windows are too generous for a long campaign |
| Back-off capped at 1 hour | about 120 per day, about 1.1% in 30 days | Still not zero: alert on repeated lockouts |
| Current step at Unix time 1,800,000,000 | 60,000,000 | Fits easily in a safe integer; the counter is written as 64-bit big-endian |
02Interfaces first
export interface TotpConfig {
digits: 6 | 8;
periodSec: number; // 30 in practice; authenticator apps assume it
t0Sec: number; // 0 = Unix epoch
skewSteps: number; // 1 => accept steps T-1, T, T+1
issuer: string; // label shown in the authenticator app
}
export interface TotpRecord {
userId: string;
secretCiphertext: Buffer; // envelope-encrypted, AAD = userId
lastStep: number; // highest step ever accepted; -1 before first use
confirmed: boolean; // false until the user proves one code at enrolment
}
export interface TotpStore {
get(userId: string): Promise<TotpRecord | undefined>;
put(rec: TotpRecord): Promise<void>;
/** Atomic: set lastStep = next only if it still equals expected. False means another request won. */
advanceStep(userId: string, expected: number, next: number): Promise<boolean>;
confirm(userId: string): Promise<void>;
}
/** KMS-backed envelope encryption. open() must return a fresh buffer and throw on any tampering. */
export interface SecretSealer {
seal(plain: Buffer, aad: string): Promise<Buffer>;
open(sealed: Buffer, aad: string): Promise<Buffer>;
}
export interface Throttle {
allowed(key: string, nowSec: number): Promise<boolean>;
failed(key: string, nowSec: number): Promise<void>;
succeeded(key: string): Promise<void>;
}
export interface Clock { nowSec(): number }
export type TotpResult =
| { ok: true; step: number }
| { ok: false; reason: "malformed" | "not_enrolled" | "throttled" | "invalid" | "replayed" };A password verifier only needs to recompute a one-way hash. A TOTP verifier must compute HMAC with the secret itself, so it needs the plaintext at verify time. The best you can do is keep it encrypted at rest under a key the database never sees (KMS envelope encryption) and bind each ciphertext to its user with associated data, so an attacker who can write rows cannot swap their secret onto your account.
03HOTP, TOTP and the verifier
Dynamic truncation is the step people hand-wave. HMAC-SHA1 gives 20 bytes. Take the low 4 bits of the last byte as an offset (0 to 15), read 4 bytes from there, clear the top bit so the number is positive on every platform, and reduce modulo 10^digits. The offset moving around is what makes every output bit matter.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export function hotp(secret: Buffer, counter: number, digits: 6 | 8): string {
if (secret.length < 16) throw new RangeError("secret must be at least 128 bits");
if (!Number.isSafeInteger(counter) || counter < 0) throw new RangeError("counter must be a non-negative safe integer");
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(BigInt(counter)); // 8-byte big-endian counter (RFC 4226 section 5.2)
const mac = createHmac("sha1", secret).update(msg).digest(); // 20 bytes
const off = mac[19] & 0x0f; // dynamic truncation: low nibble of the last byte
const bin = ((mac[off] & 0x7f) << 24) | (mac[off + 1] << 16) | (mac[off + 2] << 8) | mac[off + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** RFC 4648 base32 without padding, as authenticator apps expect. 20 bytes -> 32 characters. */
export function base32(buf: Buffer): string {
let bits = 0, value = 0, out = "";
for (const byte of buf) {
value = ((value << 8) | byte) & 0xffff;
bits += 8;
while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function otpauthUri(cfg: TotpConfig, account: string, secret: Buffer): string {
const label = encodeURIComponent(cfg.issuer) + ":" + encodeURIComponent(account);
const q = [["secret", base32(secret)], ["issuer", cfg.issuer], ["algorithm", "SHA1"], ["digits", String(cfg.digits)], ["period", String(cfg.periodSec)]];
return "otpauth://totp/" + label + "?" + q.map(([k, v]) => k + "=" + encodeURIComponent(v)).join("&");
}
export class TotpVerifier {
constructor(private readonly cfg: TotpConfig, private readonly store: TotpStore, private readonly sealer: SecretSealer,
private readonly throttle: Throttle, private readonly clock: Clock) {
if (cfg.digits !== 6 && cfg.digits !== 8) throw new Error("digits must be 6 or 8");
if (!Number.isInteger(cfg.periodSec) || cfg.periodSec < 15 || cfg.periodSec > 120) throw new Error("periodSec must be an integer in [15, 120]");
if (!Number.isInteger(cfg.t0Sec) || cfg.t0Sec < 0) throw new Error("t0Sec must be a non-negative integer");
if (!Number.isInteger(cfg.skewSteps) || cfg.skewSteps < 0 || cfg.skewSteps > 2) throw new Error("skewSteps must be 0, 1 or 2");
}
async enrol(userId: string, account: string): Promise<string> {
const existing = await this.store.get(userId);
if (existing?.confirmed) throw new Error("already enrolled; remove the old factor first");
const secret = randomBytes(20); // 160 bits, the RFC 4226 recommendation
await this.store.put({ userId, secretCiphertext: await this.sealer.seal(secret, userId), lastStep: -1, confirmed: false });
return otpauthUri(this.cfg, account, secret); // render as a QR code once; never log it
}
async confirmEnrolment(userId: string, code: string): Promise<TotpResult> {
const r = await this.check(userId, code, false);
if (r.ok) await this.store.confirm(userId);
return r;
}
verify(userId: string, code: string): Promise<TotpResult> {
return this.check(userId, code, true);
}
private async check(userId: string, code: string, wantConfirmed: boolean): Promise<TotpResult> {
const pattern = this.cfg.digits === 6 ? /^\d{6}$/ : /^\d{8}$/;
if (!pattern.test(code)) return { ok: false, reason: "malformed" };
const rec = await this.store.get(userId);
if (!rec || rec.confirmed !== wantConfirmed) return { ok: false, reason: "not_enrolled" };
const now = this.clock.nowSec();
if (!Number.isFinite(now)) throw new Error("clock returned a non-finite time");
if (!(await this.throttle.allowed(userId, now))) return { ok: false, reason: "throttled" };
const secret = await this.sealer.open(rec.secretCiphertext, userId);
const current = Math.floor((now - this.cfg.t0Sec) / this.cfg.periodSec);
const given = Buffer.from(code);
let matched = -1;
for (let d = -this.cfg.skewSteps; d <= this.cfg.skewSteps; d++) { // every candidate is computed: no early exit
const step = current + d;
if (step < 0) continue;
const ok = timingSafeEqual(Buffer.from(hotp(secret, step, this.cfg.digits)), given);
if (ok && matched === -1) matched = step;
}
secret.fill(0);
if (matched === -1) { await this.throttle.failed(userId, now); return { ok: false, reason: "invalid" }; }
if (matched <= rec.lastStep) { await this.throttle.failed(userId, now); return { ok: false, reason: "replayed" }; }
if (!(await this.store.advanceStep(userId, rec.lastStep, matched))) return { ok: false, reason: "replayed" }; // a concurrent request used it first
await this.throttle.succeeded(userId);
return { ok: true, step: matched };
}
}04Throttling, failure modes and tests
/** Exponential lockout: every 5th failure locks for 60 s, then 120 s, 240 s ... capped at 1 hour.
* In production keep this state in Redis with a TTL; a per-process Map lets an attacker spread guesses across pods. */
export class LockoutThrottle implements Throttle {
private readonly state = new Map<string, { failures: number; lockedUntil: number }>();
constructor(private readonly every = 5, private readonly baseSec = 60, private readonly capSec = 3600) {}
async allowed(key: string, nowSec: number): Promise<boolean> {
const s = this.state.get(key);
return !s || nowSec >= s.lockedUntil;
}
async failed(key: string, nowSec: number): Promise<void> {
const s = this.state.get(key) ?? { failures: 0, lockedUntil: 0 };
s.failures++;
if (s.failures % this.every === 0) {
const level = s.failures / this.every; // 1, 2, 3 ...
s.lockedUntil = nowSec + Math.min(this.capSec, this.baseSec * 2 ** (level - 1));
}
this.state.set(key, s);
}
async succeeded(key: string): Promise<void> {
this.state.delete(key);
}
}| Failure mode | What goes wrong | Defence |
|---|---|---|
| Replay inside the window | A shoulder-surfed or phished code works again for up to 90 s | lastStep with compare-and-set |
| Clock drift | Phone clock is 70 s off, every code fails | Skew of 1 step; optionally record observed drift per user and centre the window on it |
| Server clock wrong | Every user fails at once | NTP monitoring; alert on a spike of invalid across many users |
| Guessing campaign | Slow guessing under the lockout adds up | Exponential back-off, alert on repeated lockouts, require the password again |
| Secret leak via logs | The otpauth:// URI is the secret | Never log it; render the QR client side from a one-time response |
| Real-time phishing | A proxy page relays the fresh code within seconds | TOTP cannot stop this; WebAuthn passkeys bind to the origin and can |
Everything here makes TOTP as good as it can be, but a reverse proxy such as Evilginx relays the code to the real site while it is still valid. For admins and high-value accounts, offer passkeys (WebAuthn) and treat TOTP as the fallback.
import { describe, it, expect } from "vitest";
const RFC_SECRET = Buffer.from("12345678901234567890"); // RFC 4226 and RFC 6238 SHA-1 test key
class MemStore implements TotpStore {
private rows = new Map<string, TotpRecord>();
async get(id: string) { const r = this.rows.get(id); return r && { ...r }; }
async put(r: TotpRecord) { this.rows.set(r.userId, { ...r }); }
async advanceStep(id: string, expected: number, next: number) {
const r = this.rows.get(id);
if (!r || r.lastStep !== expected) return false;
r.lastStep = next;
return true;
}
async confirm(id: string) { const r = this.rows.get(id); if (r) r.confirmed = true; }
}
const plainSealer: SecretSealer = { seal: async (b) => Buffer.from(b), open: async (b) => Buffer.from(b) }; // test only
function setup(nowSec: number) {
const store = new MemStore();
const clock = { nowSec: () => nowSec };
const v = new TotpVerifier({ digits: 6, periodSec: 30, t0Sec: 0, skewSteps: 1, issuer: "CyArt" }, store, plainSealer, new LockoutThrottle(), clock);
void store.put({ userId: "u1", secretCiphertext: RFC_SECRET, lastStep: -1, confirmed: true });
return { v, clock };
}
describe("TOTP", () => {
it("matches the RFC 4226 HOTP vectors", () => {
expect([0, 1, 2].map((c) => hotp(RFC_SECRET, c, 6))).toEqual(["755224", "287082", "359152"]);
});
it("matches the RFC 6238 SHA-1 vectors", () => {
expect(hotp(RFC_SECRET, Math.floor(59 / 30), 8)).toBe("94287082");
expect(hotp(RFC_SECRET, Math.floor(1111111109 / 30), 8)).toBe("07081804");
});
it("accepts a code once and refuses the replay", async () => {
const { v } = setup(1_800_000_000);
const code = hotp(RFC_SECRET, 60_000_000, 6);
expect(await v.verify("u1", code)).toEqual({ ok: true, step: 60_000_000 });
expect(await v.verify("u1", code)).toEqual({ ok: false, reason: "replayed" });
});
it("accepts the previous step but not two steps back", async () => {
const { v } = setup(1_800_000_000);
expect((await v.verify("u1", hotp(RFC_SECRET, 59_999_998, 6))).ok).toBe(false);
expect((await v.verify("u1", hotp(RFC_SECRET, 59_999_999, 6))).ok).toBe(true);
});
it("lets only one of two concurrent submits win", async () => {
const { v } = setup(1_800_000_000);
const code = hotp(RFC_SECRET, 60_000_000, 6);
const results = await Promise.all([v.verify("u1", code), v.verify("u1", code)]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
});
it("locks after five failures, even for the right code", async () => {
const { v } = setup(1_800_000_000);
for (let i = 0; i < 5; i++) await v.verify("u1", "000000");
expect(await v.verify("u1", hotp(RFC_SECRET, 60_000_000, 6))).toEqual({ ok: false, reason: "throttled" });
});
});The RFC vectors prove the maths; the hostile tests prove the properties. If 000000 happens to be the valid code in the lockout test the assertion still holds, because the lock is checked before any code is computed.
05Extensions, follow-ups, how to say it
- Recovery codes: ten random codes of at least 50 bits each, stored as hashes like passwords, each single use.
- Drift tracking: store the offset of the last accepted step and centre the window on it, so a drifting phone keeps a window of one.
- Algorithm agility: the
otpauthURI can say SHA256, but many apps silently ignore it and use SHA-1. HMAC-SHA1 is still sound here (HMAC does not depend on collision resistance), so keep SHA-1 for compatibility. - Step-up: after a password reset or new device, require a fresh code even inside an existing session.
Q1Why clear the top bit in dynamic truncation?+
So the 31-bit value is the same unsigned number on every platform and language, whatever their signed integer handling. RFC 4226 specifies it to avoid signed versus unsigned ambiguity.
Q2Two requests carry the same valid code at the same instant. What happens?+
Both read lastStep -1 and both match the step. advanceStep is compare-and-set from -1, so one succeeds and the other sees false and returns replayed.
Q3Why not a skew window of five steps for user comfort?+
Each step either side adds two valid codes. Five steps means 11 valid codes per guess, nearly four times the guessing success of a window of one, and a 5.5-minute replay surface.
Q4Where do you store the secret?+
In its own table, encrypted with a data key wrapped by KMS, AAD bound to the user id, decrypted only inside verify and zeroed after. Backups inherit the encryption.
"HOTP is HMAC-SHA1 over an 8-byte counter, dynamically truncated to 31 bits and reduced to six digits; TOTP makes the counter the number of 30-second steps since the epoch. I accept one step either side, compare in constant time, and store the last accepted step so a code works once, advanced with compare-and-set so parallel submits cannot both win. Secrets are 160 random bits sealed with KMS and bound to the user. Lockout backs off exponentially because the guessing maths says a fixed window leaks a few per cent a month, and I'd still push admins to passkeys because TOTP can be relayed by a phishing proxy."
A user submits a valid code for step T. The server accepts it. Thirty seconds later an attacker submits the same code. With skew 1, what should stop it?
Related lessons
- Design an MFA and step-up serviceHLD · Design problems: Access & identityFree
- Design a secrets managerHLD · Design problems: Infrastructure & dataPro
- Post-quantum migrationSecEng · Applied cryptography engineeringFree
- Data protection: KMS, envelope encryption, tokenisationSecEng · Cloud & Platform SecurityPro