The problem: you have a TOTP secret and no phone, and the 6-digit code changes every 30 seconds
You enabled 2FA on a service and they showed you a Base32 secret like JBSWY3DPEHPK3PXP. You scanned the QR code into Google Authenticator and it works, but now you're on a laptop without your phone and need to log in. Or you're building a 2FA verification flow and need to generate codes server-side to compare against what the user typed. Or you're testing a TOTP implementation and need a reference. The code is 6 digits, it changes every 30 seconds, and typing "JBSWY3DPEHPK3PXP" into a calculator gives you nothing. The honest move is a generator that Base32-decodes the secret, computes HMAC-SHA1 against a time-based counter, runs the RFC 4226 dynamic truncation step (the one thing that matters), and shows the current code alongside the previous and next codes so you can see the 30-second window rolling.
Fastest path
Open the TOTP / Authenticator Code Generator, paste your Base32 secret, read the 6-digit code.
Input: JBSWY3DPEHPK3PXP
→ Current: 482 913 (valid for 22s)
→ Previous: 130 528 (expired 8s ago)
→ Next: 764 002 (valid in 22s)
→ Progress bar: green (>10s) → amber (5-10s) → red (<5s)
→ Copy current code → paste into 2FA prompt
The tool Base32-decoded the secret, computed counter = floor(unix_time / 30), ran HMAC-SHA1(key, counter-as-8-bytes), took mac[19] & 0x0f as the offset, extracted 4 bytes, masked the top bit with & 0x7f, and took mod 1_000_000 to get 482913. It also computed the codes at counter-1 and counter+1 in parallel. The rest of this guide is why dynamic truncation is the one thing that matters, why Base32 uses A-Z and 2-7, why the counter divides by 30, why HMAC-SHA1 is still the standard, and why the 0x7f mask exists.
The substance: one Base32 decode, one HMAC, four bytes of truncation
Dynamic truncation and the one thing that matters
The HOTP spec (RFC 4226) defines the core algorithm. The TOTP spec (RFC 6238) is HOTP with a time-based counter. The algorithm is:
const mac = await hmacSha1(key, counterBytes); // 20-byte HMAC-SHA1
const offset = mac[19] & 0x0f; // low 4 bits of last byte → 0-15
const code =
((mac[offset] & 0x7f) << 24) | // mask top bit, shift to high byte
((mac[offset + 1] & 0xff) << 16) |
((mac[offset + 2] & 0xff) << 8) |
(mac[offset + 3] & 0xff);
return code % 1_000_000; // 6-digit code
HMAC-SHA1 produces a 20-byte digest. The dynamic truncation step picks a 4-byte window from those 20 bytes — but which 4 bytes? The offset is mac[19] & 0x0f — the low 4 bits of the LAST byte, giving a value 0-15. The 4-byte window starts at that offset. The & 0x7f mask on the first byte clears the sign bit — without it, the 32-bit value could be interpreted as negative in languages with signed integers, and the mod 1_000_000 would produce a different result. The final mod 1_000_000 extracts the last 6 digits.
This is the one thing that matters because every other step is standard crypto — Base32 decode is a known alphabet, HMAC-SHA1 is a known primitive, the counter is one division. The dynamic truncation is the HOTP-specific step that turns a 20-byte MAC into a 6-digit code. Get the offset wrong (use mac[0] & 0x0f instead of mac[19]) and you get a different code that doesn't match any authenticator app. Get the mask wrong (skip & 0x7f) and you might get a negative number in some languages. Get the modulus wrong (use mod 10_000_000 for a 7-digit code) and the code is 7 digits, not 6. The 6-digit TOTP code that Google Authenticator shows is the result of this exact truncation, and any implementation that deviates produces codes that don't match.
Base32 and why the alphabet is A-Z plus 2-7
The tool's base32Decode uses the RFC 4648 Base32 alphabet: ABCDEFGHIJKLMNOPQRSTUVWXYZ234567. 32 characters = 5 bits per character (2^5 = 32). The decoder shifts 5 bits in at a time:
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
When the bit accumulator has 8 or more bits, extract a byte from the top. This is the same bit-packing pattern as Base64 (6 bits per char) but with 5 bits. The alphabet excludes 0, 1, 8, and 9 because they're easily confused with letters (0 looks like O, 1 looks like I/l, 8 looks like B, 9 looks like g). Including only A-Z and 2-7 minimizes transcription errors when humans type or read secrets.
The decoder normalizes input: toUpperCase(), strip whitespace, strip trailing = padding. This is why the tool's input handler forces uppercase and strips spaces as you type — JBSWY3DPEHPK3PXP and jbswy3dpehpk3pxp decode to the same bytes. The = padding is optional in Base32 — the encoder produces it to make the output length a multiple of 8, but the decoder strips it because the bit count tells it when to stop.
The counter and why it divides by 30
TOTP's counter is floor(unix_time / 30):
const t = BigInt(Math.floor((Date.now() / 1000 + offset * 30) / 30));
The 30-second timestep is the TOTP default (RFC 6238, parameter T0=0 and x=30). The division aligns wall-clock time to 30-second windows: at 12:00:00 the counter is N, at 12:00:29 the counter is still N, at 12:00:30 the counter is N+1. This is why TOTP codes are valid for "up to 30 seconds" — a code generated at 12:00:29 is the same as one generated at 12:00:00, but it expires in 1 second, not 30.
The tool uses BigInt for the counter because JavaScript's Number is a 64-bit float with only 53 bits of integer precision. The counter as an 8-byte big-endian integer can exceed 2^53 in the year 2100+ — but more importantly, the bit-shift operations in the counter-to-bytes conversion require BigInt because Number bit shifts are 32-bit:
const counterBytes = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
counterBytes[i] = Number(counter & 0xffn);
counter >>= 8n;
}
This writes the counter as 8 bytes big-endian (most significant byte first) — the format HOTP expects. The 0xffn is BigInt notation for the mask. The loop fills bytes 7 down to 0, shifting the counter right by 8 bits each iteration.
HMAC-SHA1 and why it's still the standard
The tool uses crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']) and crypto.subtle.sign('HMAC', cryptoKey, data). This is the Web Crypto API — the same primitive Google Authenticator, Authy, and every RFC 6238 implementation uses. SHA-1 is cryptographically broken for collision resistance (the SHAttered attack, 2017), but HMAC-SHA1 is not broken — HMAC's security doesn't depend on collision resistance, only on the hash's preimage resistance, which SHA-1 still has. This is why TOTP still uses HMAC-SHA1 in 2026: the attack that killed SHA-1 for signatures doesn't apply to HMAC.
The Web Crypto API is the right choice for three reasons: it's constant-time (the browser's native implementation avoids timing side channels that a JavaScript HMAC would have), it's audited (the browser vendor's crypto team maintains it), and it's asynchronous (the HMAC computation doesn't block the UI thread). The tool's hmacSha1 function is async and awaited — the HMAC runs in the browser's crypto thread, not the main thread.
Previous, current, next in parallel
The tool computes three codes at once:
const [c, p, n] = await Promise.all([
totp(sec, 30, 0), // current
totp(sec, 30, -1), // previous (counter - 1)
totp(sec, 30, 1), // next (counter + 1)
]);
The offset parameter shifts the time window by offset * 30 seconds before dividing. offset = -1 gives the previous 30-second window's code, offset = 1 gives the next. The three totp() calls run in parallel via Promise.all — three HMAC-SHA1 computations kicked off at once, awaited together. This is why the tool shows previous, current, and next codes the moment you enter a secret, without three sequential waits.
The previous code is useful for debugging: if your authenticator app shows a code and the tool's "current" doesn't match, check whether the app's code matches the tool's "previous" — if so, the app is one window behind (clock skew). The next code is useful for pre-filling: if you're about to log in and the current code has 2 seconds left, wait for the next one.
The 30-second timer and the left === 30 rollover
The tool's timer runs every second:
const now = Math.floor(Date.now() / 1000);
const left = 30 - (now % 30);
setTimeLeft(left);
if (left === 30) {
await refresh(activeSecret.current);
}
now % 30 gives the seconds elapsed in the current window. 30 - (now % 30) gives the seconds remaining. When left === 30, the window just rolled over (the elapsed time hit 0 and the remaining time reset to 30) — this is when the tool recomputes the code. The progress bar color follows the remaining time: green above 10s, amber from 5-10s, red below 5s. The bar width is 100 - progressPct where progressPct = ((30 - timeLeft) / 30) * 100 — the bar shrinks as time runs out.
The left === 30 check is a guard against computing too often. Without it, the tool would recompute every second — 30 HMAC computations per window instead of 1. The check ensures exactly one recomputation per window, at the moment of rollover.
otpauth:// URI parsing and random secret generation
The tool parses otpauth://totp/Example:alice@bob.com?secret=JBSWY3DPEHPK3PXP&issuer=Example URIs via new URL(uri). The URL constructor handles the otpauth scheme because the scheme is just a string — URL doesn't validate schemes, it parses them. The secret comes from searchParams.get('secret'), the issuer from searchParams.get('issuer'), and the account from the path (stripped of the //totp/ prefix and the Issuer: prefix).
Random secret generation uses crypto.getRandomValues(new Uint8Array(20)) — 20 cryptographically random bytes, then Base32-encoded to a 32-character string. 20 bytes = 160 bits, which is the recommended TOTP secret length (RFC 4226, Section 4: "The length of the shared secret is 160 bits"). crypto.getRandomValues is the Web Crypto API's CSPRNG — the same primitive the browser uses for SSL/TLS keys. The 32-character Base32 output is what you'd paste into an authenticator app's "manual entry" screen.
Gotchas
- Dynamic truncation is the one thing that matters.
offset = mac[19] & 0x0f, 4-byte window,& 0x7fmask on the first byte,mod 1_000_000. Get any of these wrong and the code won't match Google Authenticator. The offset uses the LAST byte, not the first. The mask is0x7f, not0xff. The modulus is1_000_000(6 digits), not10_000_000(7 digits). - Base32 alphabet is A-Z and 2-7, not 0-9. The digits 0, 1, 8, and 9 are excluded to avoid confusion with O, I/l, B, and g. If your secret contains 0, 1, 8, or 9, it's not valid Base32 — check whether it's Base64 or hex.
- The counter is
floor(unix_time / 30), notfloor(unix_time / 60). The 30-second timestep is the TOTP default. Some services use 60-second windows — the tool'stimestepparameter is 30, but you'd need to modify the code for 60. Google Authenticator, Authy, and most services use 30. - HMAC-SHA1 is still standard for TOTP. SHA-1 is broken for collisions (SHAttered, 2017), but HMAC-SHA1 is not broken — HMAC's security doesn't depend on collision resistance. Don't "upgrade" to HMAC-SHA256 unless your service explicitly uses it. The tool uses SHA-1 because that's what RFC 6238 mandates and what every authenticator app expects.
- Clock skew breaks TOTP. If your laptop clock is 35 seconds ahead of the server's clock, your "current" code is the server's "next" code — the login fails. Most services accept a ±1 window skew (the code from the previous or next window), but some don't. If your code doesn't work, check your system clock.
- The
& 0x7fmask prevents signed-bit overflow. Without it, the first byte of the 4-byte window could have its high bit set, making the 32-bit value negative in signed interpretation. In JavaScript this doesn't matter (JS usesNumber), but in Java, C, or Go it does. The mask is in the spec for cross-language correctness. - Never share your TOTP secret. The secret is equivalent to a second password — anyone with it can generate your 2FA codes. The tool processes the secret locally (no server roundtrip), but if you paste it into a non-local tool, it's compromised. Treat the Base32 secret like a password.
- The 20-byte secret is 160 bits, not 20 characters. Base32 encoding inflates 20 bytes to 32 characters (8 bits → 5 bits per char, 20 bytes × 8 / 5 = 32). A 16-character Base32 secret is only 80 bits — below the recommended minimum. Use 20 bytes (32 Base32 chars) for new secrets.
otpauth://URIs include the secret in plaintext. The URI is meant for QR code scanning, not for logging or sharing. If you paste an otpauth URI into a chat or ticket, the secret is in the URL. The tool's parser extracts the secret locally, but the URI itself is sensitive.- The previous/next codes are for debugging, not login. Most services reject codes from adjacent windows. If your current code doesn't work, the previous code won't either — but if the previous code matches your authenticator app, you've diagnosed clock skew.
crypto.getRandomValuesis synchronous,crypto.subtle.signis async. The random secret generation is sync (20 bytes from the CSPRNG, instant). The HMAC is async (Web Crypto runs in the browser's crypto thread). This is why the tool'srefreshfunction isasyncandawaited.- The timer reuses
left === 30for rollover detection. This works because30 - (now % 30)equals 30 only whennow % 30 === 0— the exact moment of window rollover. If the timer skips a second (e.g., the tab was backgrounded), the rollover check might miss — but the tool recomputes on the next second whereleftis 30 or less. In practice, the code is always correct within 1 second.
Summary
- Dynamic truncation is the one thing that matters. HMAC-SHA1 produces 20 bytes.
offset = mac[19] & 0x0fpicks a 4-byte window.& 0x7fmasks the sign bit.mod 1_000_000extracts 6 digits. Get any step wrong and the code doesn't match Google Authenticator. The offset uses the last byte, not the first. - Base32 uses A-Z and 2-7. 32 characters = 5 bits per char. The digits 0, 1, 8, 9 are excluded to avoid confusion with O, I/l, B, g. The decoder normalizes to uppercase, strips whitespace and
=padding.JBSWY3DPEHPK3PXPandjbswy3dpehpk3pxpdecode to the same bytes. - The counter is
floor(unix_time / 30). The 30-second timestep is the TOTP default (RFC 6238). The division aligns wall-clock time to 30-second windows. The tool usesBigIntfor the counter becauseNumberbit shifts are 32-bit. The counter is written as 8 bytes big-endian. - HMAC-SHA1 is still the standard. SHA-1 is broken for collisions, but HMAC-SHA1 is not — HMAC's security doesn't depend on collision resistance. The tool uses Web Crypto (
crypto.subtle) for constant-time, audited, async HMAC. Don't "upgrade" to SHA-256 unless your service explicitly uses it. - Previous, current, next in parallel.
Promise.allruns threetotp()calls with offsets -1, 0, +1. The previous code diagnoses clock skew (if the app matches "previous," your clock is ahead). The next code is for pre-filling when the current code has <2 seconds left. - The timer recomputes on
left === 30.30 - (now % 30)equals 30 only at window rollover. The progress bar is green >10s, amber 5-10s, red <5s. Theleft === 30guard ensures one recomputation per window, not 30. - 20 random bytes = 32 Base32 chars = 160 bits. The recommended TOTP secret length.
crypto.getRandomValuesis the CSPRNG. The otpauth:// URI includes the secret in plaintext — treat it like a password. - Generate codes at the TOTP / Authenticator Code Generator; for secure passwords use Password Generator, for signed tokens use JWT Generator, and for QR codes to scan into authenticator apps use QR Code Generator.