DEV Community

SEN LLC
SEN LLC

Posted on

Implementing TOTP From Scratch — RFC 6238 Test Vectors and Web Crypto

Implementing TOTP From Scratch — RFC 6238 Test Vectors and Web Crypto

TOTP is HOTP with counter = floor(now / 30). HOTP is dynamic truncation of HMAC-SHA1 output. HMAC-SHA1 is available via Web Crypto. So a TOTP generator that matches Google Authenticator exactly is about 50 lines of code — plus a correct base32 decoder and careful attention to RFC 6238 Appendix B test vectors.

Writing your own TOTP feels like something you shouldn't do. "Don't roll your own crypto" — but TOTP isn't crypto. It's a thin wrapper around HMAC-SHA1, and both RFC 4226 (HOTP) and RFC 6238 (TOTP) publish exact test vectors so you can verify correctness. If your code matches the test vectors, it matches Google Authenticator.

🔗 Live demo: https://sen.ltd/portfolio/totp-generator/
📦 GitHub: https://github.com/sen-ltd/totp-generator

Screenshot

Features:

  • TOTP / HOTP implementation (RFC 6238, RFC 4226)
  • Web Crypto API (Node crypto fallback for tests)
  • Base32 decoder (RFC 4648)
  • otpauth:// URL parsing for easy import
  • Multiple accounts with localStorage persistence
  • AES-GCM encrypted export/import (PBKDF2)
  • Circular progress countdown
  • Japanese / English UI
  • Zero dependencies, 47 tests (all RFC vectors match)

The HOTP algorithm

HOTP (HMAC-based OTP, RFC 4226) takes a secret and a counter, returns a 6-digit code:

export async function hotpFromBytes(secretBytes, counter, digits = 6) {
  const counterBytes = new Uint8Array(8);
  const view = new DataView(counterBytes.buffer);
  view.setUint32(0, Math.floor(counter / 0x100000000));
  view.setUint32(4, counter >>> 0);

  const hash = await hmacSha1(secretBytes, counterBytes);

  // Dynamic truncation (RFC 4226 section 5.3)
  const offset = hash[hash.length - 1] & 0x0F;
  const binary = 
    ((hash[offset    ] & 0x7F) << 24) |
    ((hash[offset + 1] & 0xFF) << 16) |
    ((hash[offset + 2] & 0xFF) <<  8) |
    ((hash[offset + 3] & 0xFF)      );

  const code = binary % Math.pow(10, digits);
  return code.toString().padStart(digits, '0');
}
Enter fullscreen mode Exit fullscreen mode

Three details that are easy to get wrong:

  1. Counter is 8 bytes big-endian — not 4, not little-endian. Split across two 32-bit DataView writes to handle counters beyond 2³².
  2. Offset is the low 4 bits of the last hash byte — this is the "dynamic" in dynamic truncation. Different hash values pick different starting positions.
  3. Mask the high bit of the first selected byte (& 0x7F) — this ensures the 31-bit integer is non-negative, so the modulo works correctly without needing BigInt.

TOTP = HOTP with time-based counter

export async function totp(secret, timestamp = Date.now(), step = 30, digits = 6) {
  const secretBytes = base32Decode(secret);
  const counter = Math.floor(timestamp / 1000 / step);
  return hotpFromBytes(secretBytes, counter, digits);
}
Enter fullscreen mode Exit fullscreen mode

The counter is the number of 30-second intervals since the Unix epoch. Both parties (server and client) agree on the time and step, so they derive the same counter and compute the same code.

RFC 6238 Appendix B test vectors

The key test: does your code match these exact values?

Time Code
59 94287082
1111111109 07081804
1111111111 14050471
1234567890 89005924
2000000000 69279037
20000000000 65353130

Note 07081804 — leading zero, so you must pad to the full digit count. Also note 20 billion seconds (year 2603), which is beyond 32-bit timestamps — this is why the counter handling must support full 64-bit values.

The secret is the ASCII bytes "12345678901234567890" — 20 bytes, not base32-decoded. For the test I wrote a separate totpFromBytes entry point that skips the base32 step.

Web Crypto API HMAC

async function hmacSha1(keyBytes, dataBytes) {
  if (typeof crypto !== 'undefined' && crypto.subtle) {
    const key = await crypto.subtle.importKey(
      'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
    );
    const sig = await crypto.subtle.sign('HMAC', key, dataBytes);
    return new Uint8Array(sig);
  }
  // Node fallback for tests
  const { createHmac } = await import('node:crypto');
  return new Uint8Array(createHmac('sha1', keyBytes).update(dataBytes).digest());
}
Enter fullscreen mode Exit fullscreen mode

The browser gets Web Crypto; Node tests get the built-in crypto module. Same function signature, same output.

Parsing otpauth:// URLs

Google Authenticator exports accounts as otpauth://totp/Label?secret=XXX&issuer=YYY. The parser uses URL API with a small cleanup for the path:

export function parseOtpauthUrl(url) {
  const u = new URL(url);
  if (u.protocol !== 'otpauth:') return null;
  const label = decodeURIComponent(u.pathname.replace(/^\//, ''));
  const params = u.searchParams;
  return {
    label,
    secret: params.get('secret'),
    issuer: params.get('issuer'),
    algorithm: params.get('algorithm') || 'SHA1',
    digits: parseInt(params.get('digits') || '6'),
    period: parseInt(params.get('period') || '30'),
  };
}
Enter fullscreen mode Exit fullscreen mode

Base32 (RFC 4648)

TOTP secrets are base32, not base64, because they're case-insensitive and exclude confusable characters (no 0/O, no 1/I/L). 5 bits per character, packed into 8-bit bytes:

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function base32Decode(str) {
  const clean = str.toUpperCase().replace(/[=\s]/g, '');
  const bytes = [];
  let bits = 0, value = 0;
  for (const c of clean) {
    const i = ALPHABET.indexOf(c);
    if (i === -1) continue;
    value = (value << 5) | i;
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >> (bits - 8)) & 0xFF);
      bits -= 8;
    }
  }
  return new Uint8Array(bytes);
}
Enter fullscreen mode Exit fullscreen mode

Accumulate bits 5 at a time, emit bytes when you have 8 or more. Pad characters (=) and whitespace are ignored.

Security note

The tool stores secrets in localStorage. That's less secure than a dedicated phone app because anyone with access to your browser profile can read them. The UI has a prominent warning explaining this trade-off, and the export feature uses AES-GCM with PBKDF2 (100k iterations) if you want to back up encrypted.

Series

This is entry #53 in my 100+ public portfolio series.

Top comments (0)