The Algorithm in Five Steps
TOTP — the six digits your bank app shows for thirty seconds — is specified in RFC 6238, and it is dramatically simpler than its reputation suggests. It is a thin wrapper around HOTP (RFC 4226), which is itself a thin wrapper around HMAC. The entire algorithm is five steps:
- Take the shared secret (a random byte string, usually stored Base32-encoded) and the current time.
- Compute a counter:
floor(unixSeconds / 30). This is what makes it time-based — the counter changes every 30 seconds. - HMAC the 8-byte big-endian counter with the secret as key. In practice this means HMAC-SHA1, because that's what every server and every QR code in the wild uses.
- Apply dynamic truncation to the HMAC output to extract a 31-bit integer.
- Reduce modulo 10⁶ (or 10⁷, 10⁸) and zero-pad to the digit count.
No network, no server call, no state. Both sides — your device and the server — independently derive the same six digits from the same secret and the same clock. That is the whole trick, and it means a fully offline implementation is not a compromise; it is the design's natural form.
The pieces you cannot get from Web Crypto directly are Base32 (it has no decoder) and the truncation math (it has no opinion). Everything else is crypto.subtle. Let's build each piece.
Base32 Without a Dependency
Secrets travel as RFC 4648 Base32: the alphabet is A–Z then 2–7, five bits per character, with optional = padding at the end. The string is frequently shown in groups with spaces (JBSW Y3DP EHPK 3PXP), sometimes lowercase, sometimes with padding stripped. A decoder that chokes on any of those variations will reject valid secrets copied from real services.
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Decode(input) {
// Tolerate the formatting variations seen in the wild
const clean = input.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase();
let bits = 0, value = 0;
const bytes = [];
for (const ch of clean) {
const idx = ALPHABET.indexOf(ch);
if (idx === -1) throw new Error(`Invalid base32 character: ${ch}`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
value &= (1 << bits) - 1; // drop consumed bits before the next shift
}
}
return new Uint8Array(bytes);
}
That last line — masking off the consumed bits — is the difference between a decoder that works for short secrets and one that silently corrupts long ones. Without it, value keeps growing on every shift; past 32 bits, JavaScript's bitwise operators wrap and the decoded bytes become garbage. The bug hides for secrets under ~6 bytes and appears exactly when someone pastes a 32-character secret.
The character set check matters too: decoding a mistyped secret into almost the right bytes produces codes that are always wrong, which is far more confusing for the user than an immediate "invalid secret" error.
The Counter and the HMAC
The counter is a 64-bit integer, and it must be serialized big-endian into exactly 8 bytes — that is what the RFC specifies and what servers verify. The temptation is to hand-roll it with bit shifts; the correct tool is DataView.setBigUint64, which exists precisely for this:
async function hmac(secretBytes, counter, algorithm = 'SHA-1') {
const key = await crypto.subtle.importKey(
'raw', secretBytes,
{ name: 'HMAC', hash: algorithm },
false, ['sign']
);
const msg = new ArrayBuffer(8);
new DataView(msg).setBigUint64(0, BigInt(counter)); // big-endian by default
return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
}
Notes that save time later:
HMACwithSHA-1is fully supported by Web Crypto in every modern browser. SHA-1's collision weaknesses are irrelevant here — HMAC-SHA1 does not rely on collision resistance, which is why RFC 4226 chose it and why it remains the interoperable default.setBigUint64defaults to big-endian; passingtruefor the little-endian flag is a classic silent bug that produces valid-looking but wrong codes.- When parsing
otpauth://URIs, thealgorithmparameter arrives asSHA1,SHA256, orSHA512— normalize to the Web Crypto spellings'SHA-1','SHA-256','SHA-512'. An absent parameter means SHA-1. importKeyparses and validates the key material every call. If you generate a code every second for a ticking countdown, cache the resultingCryptoKeyper secret instead of re-importing it each tick.
Dynamic Truncation and Formatting
HMAC output is 20 bytes of noise; the code lives in four of them. Dynamic truncation picks the offset non-obviously — the low nibble of the last byte — then reads a 31-bit big-endian integer starting there:
function truncate(mac, digits) {
const offset = mac[mac.length - 1] & 0x0f; // 0..15
const code =
((mac[offset] & 0x7f) << 24) | // 31 bits, sign bit cleared
( mac[offset + 1] << 16) |
( mac[offset + 2] << 8) |
mac[offset + 3];
return String(code % 10 ** digits).padStart(digits, '0');
}
Three details are load-bearing. The & 0x7f masks the top bit so the value is always positive — skip it and JavaScript's signed 32-bit shifts produce negative numbers and negative codes. 10 ** digits with padStart handles the leading-zero case ("07081804" is a real RFC test vector; naive String(code) would print "7081804"). And the modulo is taken after masking, not by slicing the last N digits — those differ.
Wrapping it all into the generator that the rest of the app talks to:
async function generateTOTP(secretBytes, {
digits = 6, period = 30, algorithm = 'SHA-1', timestamp = Date.now(),
} = {}) {
const counter = Math.floor(timestamp / 1000 / period);
const mac = await hmac(secretBytes, counter, algorithm);
return truncate(mac, digits);
}
// Code for right now:
// await generateTOTP(base32Decode('JBSWY3DPEHPK3PXP')) → '…six digits…'
Proving It Against RFC 6238
Cryptographic code that "looks right" is worthless; cryptographic code that matches published test vectors is a different category of thing. RFC 6238 ships vectors for the SHA-1 secret 12345678901234567890 (ASCII), which Base32-encodes to:
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
The expected 8-digit codes at specific timestamps:
const VECTORS = [
{ t: 59, expected: '94287082' },
{ t: 1111111109, expected: '07081804' },
{ t: 1234567890, expected: '89005924' },
{ t: 2000000000, expected: '69279037' },
];
const secret = base32Decode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');
for (const { t, expected } of VECTORS) {
const actual = await generateTOTP(secret, { digits: 8, timestamp: t * 1000 });
console.assert(actual === expected,
`RFC 6238 vector failed at T=${t}: got ${actual}, want ${expected}`);
}
Run this once in a unit test and you have permanent proof that Base32, the big-endian counter, HMAC, and truncation are all correct simultaneously — because a vector only matches if every stage matches. Note the 07081804 vector in particular: it is the one that catches missing zero-padding, which the other three would let slip through.
If you later add SHA-256 or SHA-512 accounts, note that the RFC's vectors for those use longer secrets (32 and 64 bytes) — the SHA-1 row above only validates the SHA-1 path.
Verification with Clock Drift and Constant-Time Compare
Generating codes is the easy half. Accepting them correctly has two pieces:
Clock drift. A user's device clock is rarely more than a second off, but "rarely" is not "never" — NTP sync can lag, users travel with manual clocks. Every real authenticator accepts a window of ±1 timestep (±30 s), and good ones expose the drift they observed so the user can fix their clock:
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;
}
async function verifyTOTP(input, secretBytes, {
digits = 6, period = 30, algorithm = 'SHA-1', window = 1,
} = {}) {
const now = Date.now();
for (let w = -window; w <= window; w++) {
const candidate = await generateTOTP(secretBytes, {
digits, period, algorithm,
timestamp: now + w * period * 1000,
});
if (timingSafeEqual(candidate, input.trim())) return w; // drift in steps
}
return null;
}
Replay. A ±1 window means the same code is valid for up to 90 seconds — and a code observed by an attacker (shoulder-surf, screen share, a compromised paste buffer) works for the rest of that window. The fix is one line of state: record the counter value of the last accepted code per account and refuse any verification that matches a counter ≤ the recorded one. Then a replayed code fails even inside its validity window.
For the comparison itself, the XOR-accumulate loop above avoids the short-circuit behavior of ===. In a local, offline app the timing channel is mostly theoretical — but the cost of writing the safe version is four lines, and it means nobody has to relitigate the question later.
Parsing otpauth:// URIs
The universal interchange format is the Google Authenticator key URI: otpauth://totp/Label?secret=...&issuer=...&algorithm=SHA1&digits=6&period=30. The URL constructor does the heavy lifting, but there are several awkward cases in the wild: the label may carry the issuer as a Issuer:account prefix, the issuer query parameter may or may not match the label prefix, and both may contain spaces and unicode.
function parseOtpauthUri(uri) {
const u = new URL(uri);
if (u.protocol !== 'otpauth:') throw new Error('Not an otpauth URI');
if (u.host !== 'totp') throw new Error('Only TOTP is supported');
const params = u.searchParams;
const secret = params.get('secret');
if (!secret) throw new Error('Missing secret');
// Label: '/Issuer:account@example.com' or just '/account@example.com'
const label = decodeURIComponent(u.pathname.replace(/^\//, ''));
const [labelIssuer, account] = label.includes(':')
? label.split(/:(.+)/) // split on FIRST colon only
: ['', label];
// Normalize 'SHA1' / 'sha-256' / 'SHA512' → '1' / '256' / '512'
const alg = (params.get('algorithm') || 'SHA1')
.toUpperCase().replace(/[^0-9]/g, '');
return {
secretBytes: base32Decode(secret),
digits: Number(params.get('digits') || 6),
period: Number(params.get('period') || 30),
algorithm: `SHA-${alg || '1'}`, // → SHA-1 / SHA-256 / SHA-512
issuer: (params.get('issuer') || labelIssuer).trim(),
account: account.trim(),
};
}
Two parsing traps worth calling out. The label split must happen on the first colon — accounts like user:sub@example.com exist, and label.split(':') mangles them. And some issuers encode the label fully (%20 and friends) while others leave it raw; one decodeURIComponent on the whole path segment is correct, but be ready for double-encoded labels from buggy QR generators — decode defensively and prefer the issuer query parameter when both are present.
Also validate rather than trust: digits should be 6–8, period is almost always 30 (60 exists), and a secret that fails Base32 decoding should be rejected at import time with a clear message — not saved and then discovered when the first code doesn't work.
QR Scan and the Fallback That Matters
Half the point of importing via otpauth:// URIs is QR codes. Chromium provides BarcodeDetector, which makes scanning a dozen lines — feature-detect it, because Safari still doesn't ship it, and — at the time of writing — the API is not available in every Chromium context either:
async function scanOnce(video) {
if (!('BarcodeDetector' in window)) return null;
const detector = new BarcodeDetector({ formats: ['qr_code'] });
const codes = await detector.detect(video);
return codes.length ? codes[0].rawValue : null;
}
Scanning loop hygiene: request the camera once, run detect on a periodic timer against the same <video> element (not per-frame — detection is heavier than frame rate needs), and stop both tracks immediately on success. A forgot-to-stop camera is the kind of bug users notice and reviewers write about.
The fallback is not optional: manual secret entry is the primary path, and the scanner is the convenience. Every authenticator that dropped manual entry eventually heard from the user with a 32-character secret on screen and no QR in sight — enterprise enrollment flows, hardware tokens' printed backup codes, and services that only show the secret once. Accept both the raw secret and a pasted otpauth:// URI in the same input field, and detect which one you were given.
Threat Model: What Offline Does and Doesn't Buy
The reason to build an authenticator with no network code is not privacy aesthetics — it is that 2FA exists to survive a server-side breach. If your second factor's secrets sync to a cloud account, the second factor collapses back into the first whenever the attacker reaches the sync backend. An authenticator whose manifest contains no network permission at all makes that collapse impossible by construction, and that property is worth designing around:
- Secrets never leave the device — stored via
chrome.storage.local(or a SQLite/OPFS store for larger scale), never synced.chrome.storage.syncis off the table for exactly this reason. - Honesty about at-rest protection. By default
chrome.storage.localis not encrypted by the app — it is protected by the OS user profile (DPAPI on Windows, Keychain on macOS). If that is not enough, add an optional master password: derive a key with PBKDF2-SHA256 (high iteration count plus a random salt) and encrypt only thesecretfield with AES-GCM. Three rules keep that design honest instead of decorative: the derived key lives in memory only and is never written to storage, there is no "remember me", and there is no recovery path — forgetting the passphrase means erasing the vault. What you must never ship is "encryption" that stores its own key beside the ciphertext. - Clipboard hygiene is convenience, not security. Auto-clearing a copied code after ~30 s is polite; it does not stop a hostile process from reading the clipboard in the first second. Codes are one-time anyway — the replay defense above is the actual control.
- Nothing protects against a compromised device. Malware with profile access reads the secrets regardless of extension design. No client-side scheme fixes that; the mitigation is device hygiene plus recovery codes.
And one operational habit that pays off across the whole feature: never log a secret or a generated code — not to the console, not to error reports, not in a debug string. Redact at the source with a helper that formats secrets as JBSW…3PXP, so that any future logging call is already safe by default.
Production Checklist
- Base32 decoder tolerates spaces, dashes, lowercase, and missing padding — and rejects invalid characters loudly.
- Mask consumed bits in the decoder, or long secrets silently corrupt.
- Serialize the counter with
setBigUint64(big-endian); never hand-roll it with shifts. - Clear the sign bit in the first truncation byte; zero-pad the final code.
- Validate against the RFC 6238 vectors in a test — including the
07081804leading-zero case. - Accept codes within a ±1 timestep window; return the observed drift.
- Record the last accepted counter per account and reject replays inside the window.
- Compare codes with a constant-time loop.
- Parse
otpauth://URIs withnew URL; split the label on the first colon only; normalizeSHA1→SHA-1. - Feature-detect
BarcodeDetector; stop camera tracks on success and on unmount. - Always offer manual secret entry as the primary import path.
- Ship with no network permission, store secrets locally only, and never log secrets or codes.
- If you offer a master password, derive the key with PBKDF2 (random salt, high iteration count), keep it in memory only, and say plainly that losing it means losing the vault.
References
- RFC 4226 — HOTP: An HMAC-Based One-Time Password Algorithm
- RFC 6238 — TOTP: Time-Based One-Time Password Algorithm
- RFC 4648 — The Base16, Base32, and Base64 Data Encodings
- SubtleCrypto.sign() — MDN
- BarcodeDetector — MDN
- Key Uri Format — google-authenticator wiki