The six digits in an authenticator app are not sent to your phone. The app and the server each compute them from two things they share: a secret key, agreed once at enrolment, and the current time. If both have the same key and roughly the same clock, they get the same number. This guide works through the maths with the RFC test vectors, then covers what a server has to get right.
To follow along, open the TOTP / 2FA code generator. It shows every intermediate value, checks a code against nearby time steps and builds the setup QR code, all in your browser.
The shared secret and Base32
At enrolment the server makes a random secret. RFC 4226 requires at least 128 bits and recommends 160 (20 bytes). RFC 6238 adds that keys should be as long as the HMAC output: 20 bytes for SHA-1, 32 for SHA-256, 64 for SHA-512.
People sometimes type the secret by hand, so it is shown in Base32 (RFC 4648): the letters A–Z and the digits 2–7, five bits per character. There is no 0, 1, 8 or 9, so nobody mistakes 0 for O, 1 for I or 8 for B. Twenty bytes become 32 characters. Apps ignore case and spaces, and the = padding is left out. The RFC test secret is the ASCII string 12345678901234567890:
ASCII 12345678901234567890
hex 3132333435363738393031323334353637383930
Base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
Base64 encoding explained shows the same idea with six bits per character. The usual bug is to treat a hex or ASCII key as if it were Base32. That gives different bytes, so every code is wrong.
HOTP: an HMAC of a counter
RFC 4226 defines HOTP, the HMAC-based one-time password:
HOTP(K, C) = Truncate(HMAC-SHA-1(K, C)) mod 10^digits
C is a counter written as 8 bytes, most significant first. HMAC-SHA-1 of it gives 20 bytes. Dynamic truncation picks 4 of them: the low 4 bits of the last byte give an offset from 0 to 15, the 4 bytes from there are read as a number, and the top bit is cleared so the result is a positive 31-bit integer on any platform. The last step keeps the low decimal digits.
Worked example: RFC 4226, counter 0
counter 0 → 00 00 00 00 00 00 00 00
HMAC-SHA-1 cc 93 cf 18 50 8d 94 93 4c 64 b6 5d 8b a7 66 7f b7 cd e4 b0
offset last byte 0xb0 & 0x0f = 0
4 bytes cc 93 cf 18 & 0x7fffffff = 0x4c93cf18 = 1284755224
code 1284755224 mod 10^6 = 755224
For counter 1 the HMAC ends in 0xab, so the offset is 11. Bytes 11 to 14 are c1 39 7e ea. Clearing the top bit gives 0x41397eea = 1094287082, and the code is 287082. Both values are in RFC 4226 Appendix D.
In JavaScript, for browsers and Node.js:
async function hotp(key, counter, digits = 6) {
const msg = new Uint8Array(8);
new DataView(msg.buffer).setBigUint64(0, BigInt(counter));
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, msg));
const off = mac[mac.length - 1] & 0x0f;
const bin = ((mac[off] & 0x7f) << 24) | (mac[off + 1] << 16) | (mac[off + 2] << 8) | mac[off + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
const key = new TextEncoder().encode("12345678901234567890");
await hotp(key, 0); // "755224"
HOTP's weak point is the counter: the server must store it, and it drifts when the user generates codes that are never sent.
TOTP: the counter comes from the clock
RFC 6238 replaces the stored counter with the number of time steps since a start time:
T = floor((Unix time − T0) / X) T0 = 0, X = 30 seconds by default
TOTP = HOTP(K, T)
At Unix time 59, T = 1, so the HMAC is the one above for counter 1. With 8 digits the code is 94287082, the first test vector in RFC 6238 Appendix B; with 6 digits it is 287082. Two seconds can change the code:
Unix time T (hex) SHA-1, 8 digits
1111111109 00000000023523EC 07081804
1111111111 00000000023523ED 14050471
A new step started at 1111111110; the timestamp converter turns these times into dates. Unix time is UTC, so time zones don't matter; a clock that is a minute off does. Use 64-bit arithmetic: RFC 6238 requires support for times past 2038, and its last test vector is for the year 2603.
TOTP may use HMAC-SHA-256 or HMAC-SHA-512 instead of SHA-1. One trap: the RFC's text names a single 20-byte seed, but its SHA-256 and SHA-512 rows were computed with that string repeated to 32 and 64 bytes, as its reference code does.
Enrolment: the otpauth:// URI and QR code
Apps learn the secret from a URI in Google's Key Uri Format, usually shown as a QR code:
otpauth://totp/ACME%20Co:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
- The label is the issuer, a colon and the account name, percent-encoded. A space is
%20, never+, and neither part may contain a colon. - secret is the Base32 key without padding. issuer repeats the prefix; the format recommends both.
- algorithm, digits and period default to SHA1, 6 and 30. Support for other values varies between apps, and some ignore them without warning. Keep the defaults unless you control the app.
The tool's Setup QR mode builds this URI and its QR code, and the QR code generator can encode any URI as PNG or SVG. Before turning 2FA on, ask the user to type the current code. That proves the scan worked.
Checking codes on the server
Clock skew and the validation window
Phone and server clocks never agree to the second, and a code can expire while it is typed. RFC 6238 recommends accepting at most one step of network delay, so most servers compare the codes for T − 1, T and T + 1. Compare in constant time. Section 6 of the RFC also suggests recording each device's drift. A code that matches T − 1 was typed late, or comes from a phone whose clock is up to a minute slow. The generator's Check a code mode shows which step matched.
Replay protection
Within a 30-second step the same code stays valid, and RFC 6238 says the verifier MUST NOT accept it a second time after a successful login. Store the last accepted T for each user and reject any code for that step or an earlier one. For HOTP, set the counter to one past the matched value.
Rate limiting
Six digits are only a million values, and a ±1 window makes three of them valid at once. At ten guesses a second with no limit, an attacker has a 92.5% chance of getting in within a day. With five attempts and then a lockout, the chance is about 0.0015%. RFC 4226 §7.3 asks for such throttling, per account rather than per session.
Storing the secret
The server has to compute codes, so it needs the secret itself and can't store only a hash, as it would for a password. Encrypt secrets at rest with a key held outside the database (a KMS or HSM), and decrypt one only to check a code, as RFC 6238 §5.1 recommends. Show the secret once at enrolment, and never log it or put it in a URL.
Backup and recovery codes
Phones get lost. At enrolment, give users 8 to 10 single-use recovery codes, made with a secure random generator such as the password generator. Unlike the TOTP secret, these can be stored hashed (bcrypt or similar), because the server only needs to check them. Mark each one used, and let users make a new set.
TOTP vs SMS vs passkeys
- SMS codes cross the phone network, so SIM swaps and interception can expose them. NIST SP 800-63B lists SMS as a restricted authenticator.
- TOTP never leaves the device and works offline, but a fake login page can ask for the code and use it at once.
- Passkeys (WebAuthn) sign a challenge for the real site's domain only, so a phishing page gets nothing it can use. Offer passkeys where you can, with TOTP as an alternative.
Next steps
Load the RFC example in the TOTP generator and compare its intermediate values with your implementation's. The hash generator computes the HMAC step on its own, for any text and key.