Skip to content

OTP Code Generator — JavaScript source

Generate time-based one-time passwords (RFC 6238 TOTP) from a Base32 secret, with selectable algorithm, digit count, and period - updating live, entirely in your browser.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * otp-code-generator - TOTP (RFC 6238) / HOTP (RFC 4226) code generator.
 *
 * Language:   JavaScript (ES2020+, ESM - async, uses the Web Crypto API for HMAC)
 * Source:     CosmoDev polyglot showcase port of the OTP Code Generator tool,
 *             ported from cli/otp-code-generator/otp-code-generator.go (the live
 *             Go CLI twin - the authoritative reference) and src/lib/otp.ts
 *             (canonical TypeScript, which wraps the `otpauth` dependency).
 * License:    display source - part of CosmoDev's polyglot tool pages.
 *
 * Design goals:
 *   - Pure + deterministic; never throws (generate returns string | null).
 *   - Functionally equivalent to the Go/TS reference: same inputs -> same outputs
 *     (both implement RFC 6238, so tokens agree by construction).
 *   - Self-contained: Web Crypto only (no npm dependencies).
 *
 * Implements RFC 4226 (HOTP: HMAC the 8-byte counter, dynamic-truncate, mod
 * 10^digits) and RFC 6238 (TOTP: counter = floor(timestamp_ms / 1000 / period),
 * then HOTP). `generate` mirrors Generate() in the Go twin (defaults SHA1, 6
 * digits, 30-second period); `validate` accepts the current period and +/-1
 * adjacent periods (matching otpauth's default window=1). Secrets are base32
 * (RFC 4648); whitespace/case tolerated, '=' padding stripped - exactly like
 * secretFrom()/decodeSecret() in the TS/Go.
 *
 * HMAC uses the Web Crypto API (SubtleCrypto) rather than a hand-rolled hash:
 * global in browsers (secure context) and Node 20+. All entry points are async
 * (they await SubtleCrypto), so the showcase at the bottom uses top-level await.
 * ESM (`export`) so the file runs unmodified under this project's
 * `"type": "module"` and is importable without a build step.
 */

// Web Crypto algorithm names for each OTP hash. Mirrors newHasher() in the Go.
const HASH_BY_NAME = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' };

// SubtleCrypto: global in browsers (secure context) and Node 20+.
const subtle = globalThis.crypto?.subtle;

/**
 * RFC 4648 base32 decoder (NoPadding). Built by hand to avoid an npm dep and to
 * mirror Go's base32.StdEncoding.WithPadding(base32.NoPadding). Returns null on
 * any byte outside the base32 alphabet.
 */
function base32Decode(input) {
  const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
  const len = input.length;
  if (len === 0) return new Uint8Array(0);
  const out = new Uint8Array(Math.floor((len * 5) / 8));
  let bits = 0;
  let value = 0;
  let outIdx = 0;
  for (let i = 0; i < len; i++) {
    const idx = ALPHA.indexOf(input[i]);
    if (idx < 0) return null;
    value = (value << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      bits -= 8;
      out[outIdx++] = (value >>> bits) & 0xff;
    }
  }
  return out.subarray(0, outIdx);
}

/**
 * Normalize + base32-decode the secret. Twin of decodeSecret() in the Go: strip
 * whitespace, uppercase, strip '=' padding, then base32-decode. Returns null on
 * a non-alphabet character (mirrors secretFrom()'s tolerance).
 */
export function decodeSecret(secret) {
  const cleaned = String(secret).replace(/\s+/g, '').toUpperCase().replace(/=+$/, '');
  return base32Decode(cleaned);
}

function withDefaults(opts) {
  return {
    secret: opts.secret,
    algorithm: opts.algorithm || 'SHA1',
    digits: opts.digits || 6,
    period: opts.period || 30,
  };
}

/**
 * RFC 4226 section 5.4 dynamic truncation + mod 10^digits, zero-padded. Twin of
 * the truncation tail of Generate() in the Go twin: mask the top bit of the
 * 4-byte big-endian window (equiv. to Go's &0x7f on byte[offset] << 24).
 */
function truncate(digest, digits) {
  const offset = digest[digest.length - 1] & 0x0f;
  // DataView reads the 4 bytes big-endian; &0x7fffffff clears the top bit.
  const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength);
  const bin = view.getUint32(offset) & 0x7fffffff;
  return String(bin % Math.pow(10, digits)).padStart(digits, '0');
}

/**
 * RFC 4226 HOTP for opts.secret at the given 8-byte counter. Shared core:
 * generate(opts, ts) builds the counter then calls this. Returns null on a bad
 * secret/algorithm. It is the JS twin of the HOTP step inside Generate() in the
 * Go twin.
 */
export async function hotp(opts, counter) {
  const o = withDefaults(opts);
  const hashName = HASH_BY_NAME[(o.algorithm || 'SHA1').toUpperCase()];
  if (!hashName || !subtle) return null;
  const key = decodeSecret(o.secret);
  if (!key) return null;

  // 8-byte big-endian counter - RFC 4226 section 5.2. JS bitwise ops are 32-bit,
  // so split the counter into high/low 32-bit halves and write them via a
  // DataView (correct for every realistic TOTP counter, well under 2^53).
  const msg = new Uint8Array(8);
  const dv = new DataView(msg.buffer);
  const hi = Math.floor(counter / 0x100000000);
  const lo = counter >>> 0;
  dv.setUint32(0, hi >>> 0);
  dv.setUint32(4, lo);

  // HMAC the counter with the key - RFC 4226 section 5.3.
  const cryptoKey = await subtle.importKey(
    'raw',
    key,
    { name: 'HMAC', hash: hashName },
    false,
    ['sign']
  );
  const digest = new Uint8Array(await subtle.sign('HMAC', cryptoKey, msg));
  return truncate(digest, o.digits);
}

/**
 * TOTP (RFC 6238) for opts.secret at timestampMs (milliseconds since the Unix
 * epoch). The JS twin of generateTotp() in src/lib/otp.ts / Generate() in the
 * Go - defaults SHA1, 6 digits, 30-second period. Returns null on a bad
 * secret/algorithm.
 */
export async function generate(opts, timestampMs) {
  const o = withDefaults(opts);
  const counter = Math.floor(timestampMs / 1000 / o.period); // RFC 6238 section 4.2
  return hotp(opts, counter);
}

// Constant-time string compare (HMAC digest length is fixed). Mirrors Go's
// hmac.Equal - length-mismatch short-circuits to false.
function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

/**
 * Check token against opts.secret at timestampMs, accepting the current period
 * and +/-1 adjacent periods (otpauth window=1). Mirrors validateTotp() in the
 * TS / Validate() in the Go twin.
 */
export async function validate(token, opts, timestampMs) {
  const o = withDefaults(opts);
  const periodMs = o.period * 1000;
  for (const ts of [timestampMs, timestampMs - periodMs, timestampMs + periodMs]) {
    const got = await hotp(opts, Math.floor(Math.max(0, ts) / 1000 / o.period));
    if (got !== null && timingSafeEqual(got, token)) return true;
  }
  return false;
}

// Showcase vectors - run only when this file is executed directly via
// `node javascript.js` (not when imported as a library). The ESM main-module
// check (Node) is skipped in browsers, where `process` is undefined. Shared
// with the TS/Go/Rust/PHP/Python twins so every implementation is held to one
// contract. RFC = base32 of ASCII "12345678901234567890" (RFC 6238/4226 key).
if (typeof process !== 'undefined' && process.argv?.[1]) {
  const { pathToFileURL } = await import('node:url');
  if (pathToFileURL(process.argv[1]).href === import.meta.url) {
    const assert = (await import('node:assert/strict')).default;
    const RFC = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
    assert.strictEqual(await hotp({ secret: RFC }, 0), '755224');                            // RFC 4226 c=0
    assert.strictEqual(await generate({ secret: RFC, digits: 8 }, 59000), '94287082');       // RFC 6238 T=59s
    assert.strictEqual(await generate({ secret: 'JBSWY3DPEHPK3PXP' }, 1700000000000), '324550');       // Go-twin lock-step
    assert.strictEqual(await generate({ secret: 'jbsw y3dp ehpk 3pxp' }, 1700000000000), '324550');    // spaces/lowercase
    assert.strictEqual(await generate({ secret: '!!!not-base32!!!' }, 0), null);             // invalid secret
    assert.strictEqual(await validate('324550', { secret: 'JBSWY3DPEHPK3PXP' }, 1700000000000), true); // round-trip
    console.log('otp: all showcase vectors passed');
  }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →