Implementing TOTP (Google Authenticator) from scratch: RFC 6238 explained

TOTP — Time-based One-Time Passwords, specified in RFC 6238 — is the algorithm behind Google Authenticator, Authy, and every "6-digit code" second factor. Libraries exist that wrap it up in a single function call, but understanding what's actually happening matters when you need to debug clock drift issues, tune your window tolerance, or audit your security posture. This post implements TOTP from the ground up.

The algorithm in three steps

TOTP is built on HOTP (RFC 4226), which is built on HMAC-SHA1. The three steps are:

  1. Compute the time step: T = floor(unix_time / 30)
  2. Compute HMAC-SHA1 of T using the shared secret
  3. Extract a 6-digit code via dynamic truncation

That's it. Let's implement each step.

Step 1: The time counter

// T is a 64-bit big-endian integer
function getTimeStep(timestamp: number = Date.now(), period: number = 30): Buffer {
  const T = Math.floor(timestamp / 1000 / period);
  const buf = Buffer.alloc(8);
  // Write as big-endian 64-bit int (Node's Buffer doesn't have writeUInt64BE)
  // For TOTP values within reasonable time ranges, the high 4 bytes are 0
  buf.writeUInt32BE(Math.floor(T / 0x100000000), 0);
  buf.writeUInt32BE(T >>> 0, 4);
  return buf;
}

The 30-second period is the RFC default and what every authenticator app uses. Some implementations use 60 seconds — the period value is encoded in the QR code URI so the app knows what to use.

Step 2: HMAC-SHA1

RFC 6238 specifies HMAC-SHA1 as the default. SHA-256 and SHA-512 variants exist but aren't widely supported by authenticator apps, so stick with SHA1 unless you control both sides.

import { createHmac } from 'crypto';

function hotp(secret: Buffer, counter: Buffer): number {
  const hmac = createHmac('sha1', secret);
  hmac.update(counter);
  const digest = hmac.digest(); // 20-byte (160-bit) output

  // Dynamic truncation: use last nibble of digest as offset
  const offset = digest[19] & 0x0f;

  // Extract 4 bytes starting at offset, mask high bit
  const code =
    ((digest[offset] & 0x7f) << 24) |
    ((digest[offset + 1] & 0xff) << 16) |
    ((digest[offset + 2] & 0xff) << 8) |
    (digest[offset + 3] & 0xff);

  // Return 6-digit code
  return code % 1_000_000;
}

The dynamic truncation step is what makes this algorithm interesting. Rather than using a fixed slice of the HMAC output, it uses the last byte's low nibble as an offset into the digest. This was designed to reduce predictability even if a portion of the HMAC output is known.

Step 3: Full TOTP with padding

function totp(base32Secret: string, options: { period?: number; digits?: number } = {}): string {
  const { period = 30, digits = 6 } = options;
  const secret = base32Decode(base32Secret);
  const counter = getTimeStep(Date.now(), period);
  const code = hotp(secret, counter);

  // Zero-pad to the required digit count
  return code.toString().padStart(digits, '0');
}

Base32 encoding

TOTP secrets are encoded in base32 (RFC 4648) rather than base64. This is a deliberate UX decision: base32 uses only uppercase letters A–Z and digits 2–7, which means no ambiguous characters like 0/O or 1/l/I. When users need to type a secret manually, this matters.

const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

function base32Decode(input: string): Buffer {
  // Remove padding and uppercase
  const normalized = input.toUpperCase().replace(/=+$/, '').replace(/\s/g, '');
  let bits = 0;
  let value = 0;
  const output: number[] = [];

  for (const char of normalized) {
    const idx = BASE32_ALPHABET.indexOf(char);
    if (idx === -1) throw new Error(`Invalid base32 character: ${char}`);
    value = (value << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      output.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(output);
}

function base32Encode(buf: Buffer): string {
  let bits = 0;
  let value = 0;
  let output = '';
  for (const byte of buf) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
      bits -= 5;
    }
  }
  if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
  return output;
}

// Generate a new TOTP secret (160 bits = 20 bytes, base32 = 32 chars)
function generateTotpSecret(): string {
  const secret = require('crypto').randomBytes(20);
  return base32Encode(secret);
}

The QR code URI format

Authenticator apps use a standard URI scheme to import secrets via QR code. The format is defined in the Google Authenticator Key URI Format specification:

function totpUri(params: {
  secret: string;
  issuer: string;
  account: string; // usually user's email
  period?: number;
  digits?: number;
}): string {
  const { secret, issuer, account, period = 30, digits = 6 } = params;
  const label = encodeURIComponent(`${issuer}:${account}`);
  const query = new URLSearchParams({
    secret,
    issuer,
    algorithm: 'SHA1',
    digits: digits.toString(),
    period: period.toString(),
  });
  return `otpauth://totp/${label}?${query}`;
}

// Example:
// otpauth://totp/Bastionary%3Ajames%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Bastionary&algorithm=SHA1&digits=6&period=30

Pass this URI to a QR code library like qrcode on npm to generate the scannable image. Display the raw base32 secret alongside the QR code so users can manually enter it if their camera doesn't work.

Clock drift and the validation window

TOTP codes are only valid for 30 seconds, but clocks drift. A user's phone might be 15 seconds behind or ahead of your server. The standard approach is to accept codes from one time step in either direction — i.e., validate against T-1, T, and T+1:

function verifyTotp(
  base32Secret: string,
  userCode: string,
  options: { window?: number; period?: number } = {}
): boolean {
  const { window = 1, period = 30 } = options;
  const secret = base32Decode(base32Secret);
  const now = Date.now();
  const currentStep = Math.floor(now / 1000 / period);

  for (let delta = -window; delta <= window; delta++) {
    const counter = getTimeStep((currentStep + delta) * period * 1000, period);
    const expected = hotp(secret, counter);
    const expectedStr = expected.toString().padStart(6, '0');
    if (expectedStr === userCode.trim()) return true;
  }
  return false;
}
Do not increase the window beyond ±1 without strong reason. A window of ±2 gives an attacker a 5-minute window to brute-force codes. RFC 6238 recommends ±1 as the maximum for typical deployments.

Rate limiting and replay prevention

A 6-digit TOTP has only 1,000,000 possible values. Without rate limiting, an attacker who can make many requests per second has a reasonable chance of guessing it within the 30-second window. You must:

  1. Rate limit the verification endpoint — no more than 5–10 attempts before lockout.
  2. Track used codes — once a code has been accepted, mark it as used for the remainder of its validity window. This prevents replay attacks where an attacker captures the code over the shoulder.
async function verifyTotpWithReplayPrevention(
  userId: string,
  base32Secret: string,
  userCode: string,
  redis: Redis
): Promise<boolean> {
  const replayKey = `totp:used:${userId}:${userCode}`;

  // Check if this exact code was already used
  const alreadyUsed = await redis.get(replayKey);
  if (alreadyUsed) return false;

  const valid = verifyTotp(base32Secret, userCode);
  if (valid) {
    // Mark as used for 90 seconds (3 time steps to cover window drift)
    await redis.setex(replayKey, 90, '1');
  }
  return valid;
}

Backup codes

Every TOTP implementation needs backup codes — for when users lose their phone. Generate 8–10 single-use codes of 8–10 alphanumeric characters each, store them hashed, and show them to the user exactly once:

import { randomBytes, createHash } from 'crypto';

function generateBackupCodes(count = 10): { plain: string[]; hashed: string[] } {
  const plain: string[] = [];
  const hashed: string[] = [];

  for (let i = 0; i < count; i++) {
    // 5 bytes = 10 hex chars, split into two groups for readability: XXXXX-XXXXX
    const bytes = randomBytes(5);
    const code = bytes.toString('hex').toUpperCase();
    const formatted = `${code.slice(0, 5)}-${code.slice(5)}`;
    plain.push(formatted);
    hashed.push(createHash('sha256').update(formatted).digest('hex'));
  }

  return { plain, hashed };
}

// Usage during TOTP enrollment:
const { plain, hashed } = generateBackupCodes();
await db.users.storeBackupCodes(userId, hashed); // store hashed
showToUser(plain); // show once, never again

When a backup code is used, delete it from the database immediately. Backup codes are strictly single-use. After a user has used a backup code to log in, prompt them to re-enroll TOTP or generate new backup codes — their second factor is now compromised.

Enrollment flow

The enrollment flow should:

  1. Generate a secret and store it temporarily (not yet active) — in a pending state.
  2. Show the QR code and raw secret to the user.
  3. Ask the user to enter a valid code from their authenticator app to confirm enrollment.
  4. Only then mark TOTP as active on the account and generate backup codes.

Step 3 is critical. If you activate TOTP without confirming the user has successfully imported the secret, you'll lock users out of their accounts when they scan a broken QR code or fat-finger the manual entry.