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
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');
}
Three details that are easy to get wrong:
- Counter is 8 bytes big-endian — not 4, not little-endian. Split across two 32-bit DataView writes to handle counters beyond 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.
-
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);
}
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());
}
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'),
};
}
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);
}
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.
- 📦 Repo: https://github.com/sen-ltd/totp-generator
- 🌐 Live: https://sen.ltd/portfolio/totp-generator/
- 🏢 Company: https://sen.ltd/

Top comments (0)