Most TOTP bugs are key-length, clock, and replay bugs, not crypto bugs. The algorithm is short enough to work through on paper, and doing it once makes the failure modes easier to see.
Here is the full path from a shared secret and Unix time 59 to 94287082, using the RFC's test key, then the parts the RFC leaves to your server.
Three inputs: T0, X, and a counter
RFC 6238 defines the time step as T = floor((Unix time - T0) / X). T0 defaults to 0, X to 30 seconds. At Unix time 59: T = floor((59 - 0) / 30) = 1.
That counter goes into HMAC as an 8-byte big-endian integer: 00 00 00 00 00 00 00 01. Keep the width at 8 bytes even when T is small: RFC 6238 §4.2 requires support for T beyond 32 bits past 2038, and a variable-width counter breaks there.
HOTP first, TOTP second
TOTP is HOTP (RFC 4226) with a different counter source. HOTP computes HMAC-SHA-1(key, counter) and truncates. TOTP takes the counter from the clock instead of incrementing it per code. The same key and counter always produce the same digits, so two machines agree without exchanging anything.
HMAC and dynamic truncation
The RFC 6238 test key is the ASCII string 12345678901234567890, 20 bytes.
- HMAC-SHA-1 over the counter gives 20 bytes:
75a48a19d4cbe100644e8ac1397eea747a2d33ab. This is the count-1 row of Table 1 in RFC 4226 Appendix D. - The last byte is
ab. Its low four bits areb, so the truncation offset is 11. - Four bytes starting at offset 11 are
c1 39 7e ea. Clear the top bit to get a positive 31-bit integer:0x41397eea= 1094287082. - Take the value modulo 10^digits and pad:
1094287082 mod 10^8 = 94287082,1094287082 mod 10^6 = 287082.
The 8-digit result is the SHA-1 column at time 59 in RFC 6238 Appendix B. The 6-digit result is the HOTP value for count 1.
const { createHmac } = require('node:crypto');
const msg = Buffer.alloc(8); msg.writeBigUInt64BE(1n);
const h = createHmac('sha1', '12345678901234567890').update(msg).digest();
const o = h[19] & 15; // 11
const bin = h.readUInt32BE(o) & 0x7fffffff; // 1094287082
console.log(String(bin % 1e8).padStart(8, '0')); // 94287082
The previous step (T = 0) is 84755224; the next (T = 2) is 37359152. A user stuck on either while your server expects the current code has a clock offset, not a key mismatch.
The table has 18 rows, and a trap
Six times, three algorithms each: 18 rows. Three of them:
| Unix time | T (hex) | SHA-1 | SHA-256 | SHA-512 |
|---|---|---|---|---|
| 59 | 0000000000000001 | 94287082 | 46119246 | 90693936 |
| 1111111109 | 00000000023523EC | 07081804 | 68084774 | 25091201 |
| 20000000000 | 0000000027BC86AA | 65353130 | 77737706 | 47863826 |
The prose above the table names one key: the 20-byte string. Appendix A's reference code uses 32 bytes for SHA-256 (12345678901234567890123456789012) and 64 bytes for SHA-512 (the same digits repeated). The SHA-256 and SHA-512 columns only match those longer keys.
The reason is in §5.1: keys SHOULD be the length of the HMAC output. That is 20 bytes for SHA-1, 32 for SHA-256, 64 for SHA-512. HMAC itself accepts any key length, so running the SHA-256 vector with the 20-byte key does not throw. It returns a syntactically valid code that is not 46119246. That silent mismatch is the trap.
Keep the last row in your test suite too: Unix time 20000000000 is in 2603, T is 666666666, and the row fails if the timestamp is squeezed into 32 bits.
Base32 is display encoding, not key material
Users never type raw bytes. Seeds are shown in Base32 (RFC 4648 §6): letters A–Z and digits 2–7. There is no 0, 1, 8, or 9, so a seed containing one of them was copied wrong, usually O, I, B, or g. A 9-character Base32 string is invalid too: groups of 8 cannot end after 1, 3, or 6 characters. Padding is a transport detail, and otpauth:// URIs leave it out.
Length still matters. RFC 4226 §4 requires at least 128 bits and recommends 160. A 20-byte seed is 160 bits. A 10-byte seed still produces codes, so it can slip into production; it is below the minimum, and enrollment should say so.
What the server has to add
Six digits give roughly a 10^-6 chance per guess per accepted step. Everything else is policy:
-
Window. The server knows when a code arrived, not when it was generated. §5.2 recommends allowing at most one step of network delay and comparing against past steps within that delay. §6 adds a configurable drift window and suggests recording the drift detected for each token. Library defaults vary: pyotp's
verify()hasvalid_window=0, so it accepts only the current step. - Replay. §5.2 also says the verifier MUST NOT accept a second attempt after a successful validation. Store the last accepted T per user and reject any step that is not newer.
- Throttling. RFC 4226 §7.3 requires a lockout after a number of failures or a growing delay after each one. A three-step window triples the per-guess chance, which is one more reason to keep the window small.
- Key handling. Anyone holding the secret can generate every future code. Encrypt it at rest and keep it out of logs.
Generator, authenticator app, server
Three different jobs, and only one can answer "will this code be accepted".
A generator recomputes digits from a secret, a time, and settings. It keeps no per-user state and never asks a service, so a match means the arithmetic agrees and nothing more. An authenticator app stores the secret and produces a new code each period; it cannot tell whether its settings match the server. The server holds the secret plus state — last accepted step, failure count — and applies the window, replay, and rate rules.
Use a generator to reproduce a vector or check a hand-typed seed, an app for daily logins, and server-side verification for the accept/reject decision. Known SHA-1 collision attacks do not break HMAC-SHA1 (RFC 6194 §3.3), so SHA-1 remains the compatible default. Non-default settings are where app support gets uneven, so test your app in the same period before committing to SHA-256 or a 60-second step.
The full 18-row table, both longer Base32 keys, and the provisioning URI details are in the TOTP guide. To run this vector with a fixed Unix time, or check a seed you typed by hand, use the TOTP Generator.
Top comments (1)
Great walkthrough - working the vector by hand is exactly how you internalize that TOTP bugs are clock, key-length and replay bugs rather than crypto bugs. Two practical add-ons from the desktop world: on offline Electron devices, trust the system clock but log the skew (the T +/- 1 tolerance only fixes drift within one step), and derive per-service keys from a master secret with HKDF so a leaked key never exposes the base. In practice most TOTP failures we debug are the 32-byte key trap and machines that never got an NTP sync.