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.

● MediumFREEDesign problem·25 min read·Intermediate → Advanced
TOTPHOTPRFC 6238RFC 42262FAdynamic truncation

01Understand the problem

✓What you will learn
  • 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
◎Interview radar

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.

  1. enrol(userId) creates a 160-bit secret, seals it, and returns an otpauth:// URI for a QR code. The factor stays unconfirmed until the user proves one code.
  2. verify(userId, code) returns ok or a typed reason: malformed, not_enrolled, throttled, invalid, replayed.
  3. Accept the current step and at most one step either side (configurable 0 to 2).
  4. A code (more precisely, a step) is accepted at most once per user, even under concurrent requests.
  5. Failed attempts lock the factor with exponential back-off.
NumberValueWhy it matters
Valid codes per guess3 in 10^6 (skew of 1 step)Each extra skew step adds two more valid codes
Expected guesses to win, unthrottledabout 333,000At 100 requests per second that is under an hour
5 tries per 15 min, fixed480 per day, about 4.2% success in 30 daysFixed windows are too generous for a long campaign
Back-off capped at 1 hourabout 120 per day, about 1.1% in 30 daysStill not zero: alert on repeated lockouts
Current step at Unix time 1,800,000,00060,000,000Fits easily in a safe integer; the counter is written as 64-bit big-endian

02Interfaces first

The contractts
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" };
iWhy the secret cannot be hashed

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.

HOTP, enrolment and verificationts
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 };
  }
}
▭On the whiteboard · Design choices4 points
01
replay
store lastStep; accept only steps strictly greater; advance with compare-and-set so two parallel submits cannot both win
02
compare
timingSafeEqual on equal-length digit strings (the format check guarantees the length)
03
window
skew 1 = 90 seconds of validity; never more than 2, because each step adds two guessable codes
04
secret
160 random bits, sealed with AAD = userId, zeroed after use, shown once as a QR code

04Throttling, failure modes and tests

Lockout with back-offts
/** 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 modeWhat goes wrongDefence
Replay inside the windowA shoulder-surfed or phished code works again for up to 90 slastStep with compare-and-set
Clock driftPhone clock is 70 s off, every code failsSkew of 1 step; optionally record observed drift per user and centre the window on it
Server clock wrongEvery user fails at onceNTP monitoring; alert on a spike of invalid across many users
Guessing campaignSlow guessing under the lockout adds upExponential back-off, alert on repeated lockouts, require the password again
Secret leak via logsThe otpauth:// URI is the secretNever log it; render the QR client side from a one-time response
Real-time phishingA proxy page relays the fresh code within secondsTOTP cannot stop this; WebAuthn passkeys bind to the origin and can
!TOTP is not phishing resistant

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.

vitestts
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 otpauth URI 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.

“How to say it in the room

"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."

Test yourself

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?

Finished this one?
Tick it and it shows as done on the roadmap and the course.