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.

  1. HMAC-SHA-1 over the counter gives 20 bytes: 75a48a19d4cbe100644e8ac1397eea747a2d33ab. This is the count-1 row of Table 1 in RFC 4226 Appendix D.
  2. The last byte is ab. Its low four bits are b, so the truncation offset is 11.
  3. Four bytes starting at offset 11 are c1 39 7e ea. Clear the top bit to get a positive 31-bit integer: 0x41397eea = 1094287082.
  4. 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() has valid_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.