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 →