An authenticator app and the website share a secret key, and every 30 seconds both compute HMAC-SHA-1 of that key and the current time step, then shorten the result to 6 digits. If the code you type matches the one the server computes, you have proved you hold the key, without the key or anything reusable crossing the network.
The algorithm is TOTP (Time-based One-Time Password), defined in RFC 6238. It is built on HOTP (RFC 4226), which is built on HMAC (RFC 2104). Each layer is short enough to check by hand with the official test vectors below.
Step 1: the shared secret
When you enable 2FA, the site generates a random secret key and shows it as a QR code. Your app scans it and stores the key; the site stores the same key. That is the only time the secret travels. RFC 4226 requires the shared secret to be at least 128 bits and recommends 160 bits.
This is why the QR code matters: anyone who photographs it can generate your codes forever. Treat it like a password and never share a screenshot of it.
Step 2: turn the time into a counter
TOTP counts 30-second intervals since the Unix epoch (1 January 1970, 00:00:00 UTC):
T = floor( (current Unix time - T0) / X )
RFC 6238 sets the defaults: time step X = 30 seconds and T0 = 0, "the Unix epoch". At Unix time 59, T = floor(59 / 30) = 1. At Unix time 1111111109, T = 37037036, which is 00000000023523EC in hexadecimal. Everyone with the same clock gets the same T for the same 30 seconds, and a new T every 30 seconds after that.
RFC 6238 defines TOTP as "TOTP = HOTP(K, T)". In other words, it is exactly HOTP with the counter replaced by the time step.
Step 3: HMAC the counter with the secret
HOTP feeds the counter, as an 8-byte value hashed "high-order byte first" (RFC 4226), into HMAC-SHA-1 keyed with the secret. HMAC (RFC 2104) is defined as H(K XOR opad, H(K XOR ipad, text)): two nested hashes with the key mixed in, so the output can't be produced or predicted without the key. If you want to see HMAC in action on text input, larely's HMAC generator computes HMAC-SHA-1 and HMAC-SHA-256 in the browser.
HMAC-SHA-1 produces 20 bytes. For the RFC's test secret and T = 1, the result is:
75a48a19d4cbe100644e8ac1397eea747a2d33ab
Step 4: dynamic truncation to 6 digits
Twenty bytes is too long to type, so HOTP applies what RFC 4226 calls dynamic truncation, whose purpose is "to extract a 4-byte dynamic binary code" from the HMAC (section 5.3):
- Take the low 4 bits of the last byte as an offset. The last byte is
ab; its low 4 bits areb= 11. - Read 4 bytes starting at that offset (bytes are numbered from 0). Bytes 11 to 14 are
c1 39 7e ea. - Clear the top bit to get a 31-bit number:
0xc1397eeaAND0x7fffffff=0x41397eea= 1,094,287,082. - Take the number modulo 10digits. For 6 digits: 1,094,287,082 mod 1,000,000 = 287082. For 8 digits: 94287082.
Two independent checks confirm this. The 8-digit value 94287082 is the first SHA-1 row of RFC 6238's test table below. And 287082 is the HOTP value RFC 4226 lists in Appendix D for counter 1, which matches because TOTP at T = 1 is HOTP with counter 1. RFC 4226 requires at least 6 digits.
The official test vectors
RFC 6238 Appendix B publishes these values for SHA-1, with the ASCII secret 12345678901234567890 (hex 3132333435363738393031323334353637383930), X = 30, T0 = 0 and 8-digit output:
| Unix time (s) | UTC time | T (hex) | TOTP (SHA-1, 8 digits) |
|---|---|---|---|
| 59 | 1970-01-01 00:00:59 | 0000000000000001 | 94287082 |
| 1111111109 | 2005-03-18 01:58:29 | 00000000023523EC | 07081804 |
| 1111111111 | 2005-03-18 01:58:31 | 00000000023523ED | 14050471 |
| 1234567890 | 2009-02-13 23:31:30 | 000000000273EF07 | 89005924 |
| 2000000000 | 2033-05-18 03:33:20 | 0000000003F940AA | 69279037 |
| 20000000000 | 2603-10-11 11:33:20 | 0000000027BC86AA | 65353130 |
Note rows 2 and 3: Unix times 1111111109 and 1111111111 are only two seconds apart but fall in different 30-second steps, so their codes are unrelated. A 6-digit app would show the last 6 digits of each value (for example 081804 for the second row), because taking the number modulo 106 keeps its last six digits. We recomputed every SHA-1 row above with Node.js's built-in crypto.createHmac('sha1', …) and got identical results.
Why the clock must be right
The server computes the code for its own current time step. If your phone's clock is wrong, it computes the code for a different step and the codes won't match. Servers allow a little slack. RFC 6238 recommends allowing at most one time step for network delay, and lets verifiers accept a limited number of steps of clock drift; with 30-second steps and two steps of allowed drift backwards, it notes the maximum drift tolerated is about 89 seconds. Beyond the server's limit, every code fails. The fix is to turn on automatic time on the phone.
The same design is why codes work offline. The app needs only the stored key and the time, both of which are on the phone. Nothing is sent to the app when a code is generated.
SMS codes, app codes and passkeys compared
| Method | How the code arrives | Main weakness |
|---|---|---|
| SMS code | Sent by the service over the phone network | Depends on the phone network; NIST calls this "restricted" and flags SIM changes and number porting as risks |
| Authenticator app (TOTP) | Computed on your device from the shared secret | Can be phished: a fake site can relay the code to the real one in real time |
| Passkey or security key (WebAuthn/FIDO2) | No code; the device signs a challenge tied to the site | Needs support from the site and a device or key to hold the credential |
NIST's current guidelines are blunt about typed codes: "OTP authentication is not phishing-resistant," because "the manual entry does not bind the authenticator output to the specific session being authenticated." WebAuthn, the standard behind passkeys and security keys, "provides phishing resistance through verifier name binding": the browser signs for the real site's name, so a look-alike domain gets nothing usable. Passkeys also avoid a shared secret entirely: the WebAuthn standard has the site store only a public key while the authenticator signs each login with a private key that never leaves it; public-key cryptography explained covers how that works.
In practice: any second factor is much better than none. If a service offers passkeys or security keys, they protect against phishing as well. If it offers only codes, prefer an authenticator app over SMS.
Frequently asked questions
- Why does my authenticator app work without internet?
- Because the code is computed, not received. When you scanned the QR code, the app stored a secret key shared with the service. Each code is calculated from that secret and the current time using HMAC, so the app needs only the key and an accurate clock. The server does the same calculation and compares.
- Why are my 2FA codes rejected?
- The most common cause is a wrong clock on the phone. TOTP codes depend on the current 30-second time step, so a clock that is off by more than the server's tolerance produces codes for the wrong step. Turn on automatic date and time on the phone and try again. Typing a code just as it expires can also fail.
- Is SMS two-factor authentication safe?
- It is better than a password alone, but it is the weakest common option. NIST classes authentication over the phone network as 'restricted' and tells verifiers to consider risks such as SIM changes and number porting. App-based codes avoid the phone network, and passkeys or security keys also resist phishing, which no typed code does.
- What happens if I lose the phone with my authenticator app?
- You lose the secret keys stored in it, so you need another way in: the recovery codes most services give you when you enable 2FA, a second registered device, or the service's account recovery process. Save recovery codes somewhere safe when you set up 2FA, not after you lose the phone.
- Can someone who sees one of my codes log in later?
- Not with that code after it expires. A TOTP code is valid only for its time step plus whatever small tolerance the server allows, and RFC 6238 says a verifier must not accept the same code twice after a successful login. It does not protect against a phishing site that relays the code to the real site within those seconds.
Sources
- RFC 6238: TOTP, Time-Based One-Time Password Algorithm
- RFC 4226: HOTP, An HMAC-Based One-Time Password Algorithm
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication
- W3C: Web Authentication (WebAuthn) Level 2
- NIST SP 800-63B-4: Digital Identity Guidelines, Authentication and Authenticator Management