Back to All Articles
Cybersecurity•
9 min read

Implementing Zero-Dependency RFC 6238 TOTP from Scratch in Modern TypeScript

A complete, zero-dependency architectural guide to building RFC 6238 Time-based One-Time Passwords (TOTP) and RFC 4226 HOTP in TypeScript—handling Base32 decoding, HMAC-SHA1 dynamic truncation, virtualized LXC container clock drift, and single-use offline recovery codes without external cloud auth dependencies.

S
Siegfried Stehring•
Implementing Zero-Dependency RFC 6238 TOTP from Scratch in Modern TypeScript

When building self-hosted applications or private administrative consoles, Multi-Factor Authentication (MFA) is non-negotiable. Exposing an administrative surface to the internet protected solely by a static password is an invitation to credential-stuffing bots and automated dictionary attacks.

However, the modern web development ecosystem often defaults to heavy cloud authentication providers (Auth0, Clerk, Firebase, Okta) or pulls in massive npm dependency trees containing dozens of sub-packages just to verify a simple 6-digit number. For a self-hosted platform prioritizing sovereignty, air-gapped resilience, and minimal runtime footprints, relying on third-party SaaS auth vendors introduces vendor lock-in, recurring costs, and external failure domains.

In this deep dive, we walk through implementing RFC 6238 Time-Based One-Time Passwords (TOTP) and RFC 4226 HMAC-Based One-Time Passwords (HOTP) completely from scratch in modern TypeScript, using only Node.js native crypto. We will explore Base32 encoding/decoding, HMAC dynamic truncation, clock drift tolerances on virtualized hypervisors (like Proxmox LXC), and cryptographic offline recovery backup codes.


1. How TOTP Authentication Works Under the Hood

RFC 6238 (TOTP) is an extension of RFC 4226 (HOTP). While HOTP generates one-time passcodes based on an incrementing counter shared between the client and the server, TOTP replaces the counter with the current Unix timestamp divided into discrete time intervals (typically 30 seconds).

+-----------------------+              +-----------------------+
|  Mobile Authenticator |              |  Self-Hosted Server   |
| (Bitwarden/Google/iOS)|              |   (Node.js / Next.js) |
+-----------+-----------+              +-----------+-----------+
            |                                      |
   Shared Secret (K)                      Shared Secret (K)
   Current Unix Time (T)                  Current Unix Time (T)
            │                                      │
            ▼                                      ▼
    Counter C = T / 30                     Counter C = T / 30
            │                                      │
    HMAC-SHA1(K, C)                        HMAC-SHA1(K, C)
            │                                      │
    Dynamic Truncation                     Dynamic Truncation
            │                                      │
      6-Digit Code (e.g. 849201)             6-Digit Code (e.g. 849201)
            │                                      ▲
            └──────── User submits code ───────────┘
                           Verification: Match!

Because both your phone's authenticator app and your server share the secret key $K$ and agree on the current time step $C$, both compute the exact same 6-digit code independently without any network connection required between your phone and your server.


2. Step 1: Base32 Encoding & Decoding

Authenticator apps (Google Authenticator, Bitwarden, Apple Passwords, 1Password) expect shared secrets to be formatted in Base32 (RFC 4648). Base32 uses a 32-character alphabet (A-Z and 2-7), deliberately excluding 0, 1, 8, and 9 to avoid human transcription errors.

Node.js standard library supports hex and base64, but lacks native Base32. Here is a clean, zero-dependency implementation of RFC 4648 Base32:

const RFC4648_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

/**
 * Decodes an RFC 4648 Base32 string into a raw Node.js Buffer.
 */
export function base32Decode(input: string): Buffer {
  // Strip formatting spaces, dashes, and padding
  const clean = input.toUpperCase().replace(/[\s\-=]/g, '');
  const bytes: number[] = [];
  let buffer = 0;
  let bitsLeft = 0;

  for (let i = 0; i < clean.length; i++) {
    const val = RFC4648_ALPHABET.indexOf(clean[i]);
    if (val === -1) {
      throw new Error(`Invalid Base32 character encountered: ${clean[i]}`);
    }

    buffer = (buffer << 5) | val;
    bitsLeft += 5;

    if (bitsLeft >= 8) {
      bitsLeft -= 8;
      bytes.push((buffer >> bitsLeft) & 0xff);
    }
  }

  return Buffer.from(bytes);
}

/**
 * Encodes a Buffer into an RFC 4648 Base32 string without padding.
 */
export function base32Encode(buffer: Buffer): string {
  let result = '';
  let accumulator = 0;
  let bits = 0;

  for (let i = 0; i < buffer.length; i++) {
    accumulator = (accumulator << 8) | buffer[i];
    bits += 8;

    while (bits >= 5) {
      bits -= 5;
      result += RFC4648_ALPHABET[(accumulator >> bits) & 0x1f];
    }
  }

  if (bits > 0) {
    result += RFC4648_ALPHABET[(accumulator << (5 - bits)) & 0x1f];
  }

  return result;
}

Generating a secure random 20-byte (160-bit) secret key is straightforward:

import crypto from 'crypto';

export function generateSecretKey(): string {
  // 20 bytes is the recommended secret size for SHA-1 HMAC in RFC 4226
  const randomBuffer = crypto.randomBytes(20);
  return base32Encode(randomBuffer);
}

3. Step 2: The Core HOTP & TOTP Algorithm

The core mathematical engine consists of three operations:

  1. Converting the 64-bit integer counter into an 8-byte big-endian buffer.
  2. Computing the HMAC-SHA1 digest using the decoded secret key.
  3. Extracting a 4-byte dynamic slice and computing modulo $10^6$ to yield a 6-digit decimal.
/**
 * Implements RFC 4226 HOTP algorithm.
 */
export function generateHOTP(secret: string, counter: number): string {
  const key = base32Decode(secret);

  // Convert 64-bit integer counter to 8-byte big-endian Buffer
  const counterBuffer = Buffer.alloc(8);
  counterBuffer.writeBigUInt64BE(BigInt(counter), 0);

  // Compute HMAC-SHA1
  const hmac = crypto.createHmac('sha1', key);
  hmac.update(counterBuffer);
  const digest = hmac.digest();

  // Dynamic Truncation: Use low-order 4 bits of the last byte as offset
  const offset = digest[digest.length - 1] & 0x0f;

  // Read 4 bytes from offset and mask out the sign bit (MSB 0x7f)
  const binaryCode =
    ((digest[offset] & 0x7f) << 24) |
    ((digest[offset + 1] & 0xff) << 16) |
    ((digest[offset + 2] & 0xff) << 8) |
    (digest[offset + 3] & 0xff);

  // Modulo 1,000,000 to get a 6-digit code, padded with leading zeroes
  const code = (binaryCode % 1_000_000).toString().padStart(6, '0');
  return code;
}

/**
 * Implements RFC 6238 TOTP algorithm.
 */
export function generateTOTP(secret: string, timeStepSeconds: number = 30): string {
  const currentEpochSeconds = Math.floor(Date.now() / 1000);
  const timeStep = Math.floor(currentEpochSeconds / timeStepSeconds);
  return generateHOTP(secret, timeStep);
}

4. The Virtualization Problem: Clock Drift & Hypervisor Slumber

In a theoretical lab, your client device and your server are perfectly synchronized to the microsecond via NTP. In production homelabs and edge setups, reality looks very different:

  • Unprivileged Proxmox LXC containers can experience clock skew during heavy host I/O or kernel sleep states.
  • Mobile phones on flaky cell connections or international travel may experience significant network delays or slight clock drift.
  • A user typing a code that displayed at second 28 will submit it when the server has already rolled over to second 32.

If your verification logic only checks the current 30-second window ($W=0$), valid login attempts will intermittently fail, frustrating users and locking out admins.

The Solution: Asymmetric Sliding Tolerance Windows

/**
 * Verifies a user-supplied 6-digit TOTP code against a sliding time window.
 *
 * @param token The 6-digit code submitted by the user.
 * @param secret The Base32 shared secret.
 * @param window Number of 30-second steps to check backward and forward.
 *               A window of 5 permits +/- 150 seconds of drift.
 */
export function verifyTOTP(
  token: string,
  secret: string,
  window: number = 5
): boolean {
  if (!token || token.length !== 6 || !/^\d{6}$/.test(token)) {
    return false;
  }

  const currentEpochSeconds = Math.floor(Date.now() / 1000);
  const currentStep = Math.floor(currentEpochSeconds / 30);

  // Check windows: [current - window, ..., current, ..., current + window]
  for (let i = -window; i <= window; i++) {
    const candidateCode = generateHOTP(secret, currentStep + i);

    // Use timingSafeEqual to prevent side-channel timing attacks
    if (crypto.timingSafeEqual(Buffer.from(candidateCode), Buffer.from(token))) {
      return true;
    }
  }

  return false;
}

[!TIP] Always use crypto.timingSafeEqual() when comparing authentication strings or HMAC tokens. A simple === comparison terminates at the first mismatching byte, enabling attackers to deduce valid characters through microsecond-level response variations.


5. Offline Recovery Backup Codes

What happens if an administrator drops their phone in the ocean or restores their device from a backup without authenticator data? Without a recovery mechanism, they are permanently locked out of their self-hosted node.

Recovery backup codes must satisfy four security criteria:

  1. High Entropy: Minimum 8 to 10 alphanumeric characters.
  2. Hashed at Rest: Never store raw recovery codes in plain text in your database or JSON configuration. Hash them using SHA-256 or Argon2.
  3. Single-Use Burn: Once a recovery code is successfully redeemed, it must be permanently purged from the stored array.
  4. Readable Format: Formatting codes in grouped chunks (e.g., F22C-207E) drastically reduces human transcription typos.
export function generateBackupCodes(count: number = 8): { rawCodes: string[]; hashedCodes: string[] } {
  const rawCodes: string[] = [];
  const hashedCodes: string[] = [];

  for (let i = 0; i < count; i++) {
    // 8 random bytes produces 8 hex characters
    const partA = crypto.randomBytes(2).toString('hex').toUpperCase();
    const partB = crypto.randomBytes(2).toString('hex').toUpperCase();
    const formatted = `${partA}-${partB}`; // e.g. "8A2F-9C14"

    rawCodes.push(formatted);

    // Hash for persistence
    const hash = crypto.createHash('sha256').update(formatted).digest('hex');
    hashedCodes.push(hash);
  }

  return { rawCodes, hashedCodes };
}

export function redeemBackupCode(submittedCode: string, storedHashedCodes: string[]): { valid: boolean; remainingHashedCodes: string[] } {
  const clean = submittedCode.trim().toUpperCase();
  const candidateHash = crypto.createHash('sha256').update(clean).digest('hex');

  const matchIndex = storedHashedCodes.findIndex((hash) =>
    crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(candidateHash))
  );

  if (matchIndex === -1) {
    return { valid: false, remainingHashedCodes: storedHashedCodes };
  }

  // Remove the redeemed code (single-use guarantee)
  const remaining = [...storedHashedCodes];
  remaining.splice(matchIndex, 1);

  return { valid: true, remainingHashedCodes: remaining };
}

6. Pairing with Mobile Authenticators via otpauth:// QR URIs

To register the secret in Google Authenticator or Bitwarden, generate a standard Key URI format defined by Google's Authenticator specification:

$$\text{otpauth://totp/}\langle\text{Issuer}\rangle:\langle\text{Account}\rangle\text{?secret=}\langle\text{Secret}\rangle\text{&issuer=}\langle\text{Issuer}\rangle\text{&algorithm=SHA1&digits=6&period=30}$$

export function getOtpAuthUri(issuer: string, accountName: string, secret: string): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedAccount = encodeURIComponent(accountName);
  return `otpauth://totp/${encodedIssuer}:${encodedAccount}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}

When rendered as a QR code in the browser or terminal, your phone scans it in less than a second and immediately begins generating synchronized tokens.


7. Performance & Security Summary

| Metric / Feature | Cloud Auth Providers (SaaS) | Bloated npm Package Tree | Our Zero-Dependency Architecture | | :--- | :--- | :--- | :--- | | External Dependencies | Multiple API clients, SDKs | 12+ transitive npm dependencies | 0 external dependencies (Node crypto only) | | Offline / Air-Gapped | ❌ Fails when WAN is down | ⚠️ Varies | ✅ 100% operational offline | | Bundle Size Overhead | > 150 kB minified | ~45 kB | < 3 kB total TypeScript | | Clock Drift Handling | Opacity / Black box | Rigid or fixed window | ✅ Configurable dynamic sliding window | | Data Sovereignty | Credentials stored off-site | Data stored locally | ✅ Cryptographic keys stay in your container |

By implementing RFC 6238 directly, you eliminate external dependencies, protect your administrative access against credential leaks, and guarantee that your self-hosted system remains fully functional even in complete isolation from the wider internet.

Support Independent Technical Content

If this article helped you build, debug, or host your applications, consider supporting the blog on Ko-fi. Every cup of coffee helps keep this site ad-free and 100% self-hosted!

Support on Ko-fi
Tags:#TypeScript#MFA#Security#Authentication#RFC 6238#Cryptography#Node.js

Related Engineering Guides

Cybersecurity11 min read

Fronting Self-Hosted Infrastructure with Cloudflare: Edge Protection, Zero Inbound Ports, and Origin Cloaking

Why putting Cloudflare in front of your self-hosted stack is essential: shielding your origin IP, eliminating inbound firewall ports with Cloudflare Tunnel, mitigating edge DDoS attacks, offloading bandwidth, and enforcing Zero Trust security boundaries.

Read Article
Cybersecurity3 min read

Cybersecurity in a Hybrid Environment: Balancing Cloud and Local Infrastructure

Crucial considerations for securing hybrid IT environments, from access management and least privilege to unified logging and data sovereignty.

Read Article
Web Development7 min read

Building Cookie-Free, Privacy-First Web Telemetry That Adblockers Don't Break

Why we ditched Google Analytics and built a lightweight, 100% self-hosted visitor telemetry engine in Next.js: using cryptographic daily rotating salt hashes, offline MaxMind GeoIP lookups, navigator.sendBeacon, and zero third-party cookie banners.

Read Article