← Back to Blog

TOTP from Scratch: Building an Offline 2FA Authenticator with Web Crypto

October 2026~15 min read

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:

  1. Take the shared secret (a random byte string, usually stored Base32-encoded) and the current time.
  2. Compute a counter: floor(unixSeconds / 30). This is what makes it time-based — the counter changes every 30 seconds.
  3. 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.
  4. Apply dynamic truncation to the HMAC output to extract a 31-bit integer.
  5. 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:

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:

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

  1. Base32 decoder tolerates spaces, dashes, lowercase, and missing padding — and rejects invalid characters loudly.
  2. Mask consumed bits in the decoder, or long secrets silently corrupt.
  3. Serialize the counter with setBigUint64 (big-endian); never hand-roll it with shifts.
  4. Clear the sign bit in the first truncation byte; zero-pad the final code.
  5. Validate against the RFC 6238 vectors in a test — including the 07081804 leading-zero case.
  6. Accept codes within a ±1 timestep window; return the observed drift.
  7. Record the last accepted counter per account and reject replays inside the window.
  8. Compare codes with a constant-time loop.
  9. Parse otpauth:// URIs with new URL; split the label on the first colon only; normalize SHA1 → SHA-1.
  10. Feature-detect BarcodeDetector; stop camera tracks on success and on unmount.
  11. Always offer manual secret entry as the primary import path.
  12. Ship with no network permission, store secrets locally only, and never log secrets or codes.
  13. 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

Related Articles