
You've used TOTP a thousand times. Open the authenticator app, type in 6 digits, done. But what's actually happening between your phone and the server to make that work?
The short version: both sides share a secret key, both sides know what time it is, and there's some clever math to turn those two things into the same 6-digit number independently, without talking to each other. No network round-trip, no SMS, no third party
The whole thing is defined in RFC 6238, which itself builds on RFC 4226 (HOTP, the counter-based predecessor). TOTP is basically HOTP but with time replacing the counter. The RFC is surprisingly readable, but let's tear it apart
Table of Contents
- The Shared Secret
- The Time Window
- HMAC-SHA1
- Dynamic Truncation
- Security Properties
- A Working Rust Implementation
The Shared Secret
Everything starts with a secret key. When you set up 2FA, the server generates a random key and shows it to you once, typically as a QR code. Your authenticator app scans it and stores it. The server stores it too
The RFC recommends the key length should match the HMAC output length, so 20 bytes for HMAC-SHA1, 32 bytes for HMAC-SHA256, or 64 bytes for HMAC-SHA512. In practice most deployments use HMAC-SHA1 with a 20-byte key, and that's what we'll focus on here
That key never crosses the wire again. No "send me a code" request, no request-response protocol. Both sides just have the key and use it locally. This is the property that makes TOTP work offline, this is why your phone doesn't need internet to generate a valid code
The QR code is usually a otpauth:// URI that looks like:
otpauth://totp/elijahkoulaxis:elijah@koulaxis.com?secret=JBSWY3DPEHPK3PXP&issuer=elijahkoulaxis
The secret parameter is the key, base32-encoded. Decode it and you've got your raw bytes
The Time Window
TOTP needs both sides to produce the same input to the hash function without communicating. The trick: use time itself as the input
RFC 6238 defines it as: T = floor((Current Unix time - T0) / X), where T0 is the start time (defaults to the Unix epoch, 0) and X is the time step (defaults to 30 seconds). In practice T0 is almost always 0, so this simplifies to:
T = floor(unix_timestamp / 30)
Right now, as I write this, unix_timestamp is somewhere around 1776094529. Divide by 30 and you get 59203150. In 30 seconds it'll be 59203151. Both sides will independently arrive at the same number
One important detail from the RFC: the implementation MUST support a time value T larger than a 32-bit integer. This matters after 2038 when unix timestamps overflow 32 bits. Using a 64-bit integer for T covers this (and is what our Rust implementation does below)
That counter goes into the hash function. Same counter + same secret = same output = same code. Simple
The 30-second window also gives you some slack. If your phone's clock is off by a few seconds, you'll still land in the same bucket. The RFC recommends servers accept at most one extra time step in each direction as tolerance for network delay, so you get roughly ±30 seconds of drift tolerance
HMAC-SHA1
We need to combine the secret and the counter into something that only the secret-holder could produce. You can't just do SHA1(secret + counter_bytes) because that's vulnerable to length extension attacks
HMAC solves this by hashing the key with the message twice, using two different paddings of the key. The double wrapping means you can't forge the output without knowing the key
HMAC(K, m) = SHA1((K ⊕ opad) || SHA1((K ⊕ ipad) || m))
The core formula comes from RFC 4226 (HOTP): HOTP(K, C) = Truncate(HMAC-SHA-1(K, C)). TOTP just swaps the event counter C for the time-derived counter T. Everything else, the HMAC, the truncation, is identical
So we compute HMAC-SHA1(secret, counter_bytes) and get back 20 bytes of pseudorandom output that only someone with the secret could produce for that specific counter value
Dynamic Truncation
We've got 20 bytes. We need 6 digits. So we need to truncate, and it's my favorite part because it's so simple and clever at the same time
Let's say our HMAC output (20 bytes) looks like this:
1f 86 98 69 0e 02 ca 16 61 85 50 ef 7f 19 da 8e 94 5b 55 5a
^^
Step 1: Look at the last byte. That's 0x5a. We need a value between 0 and 15 to use as an offset (so we can read 4 bytes starting there without going past byte 19). The low 4 bits of any byte give exactly that range, so: 0x5a & 0x0f = 10. Offset is 10
Step 2: Starting at byte index 10, grab 4 bytes: 50 ef 7f 19
Step 3: We have 4 bytes: 50 ef 7f 19. We need to pack them into a single 32-bit number
A 32-bit number is 4 bytes laid out side by side. The first byte we grabbed (50) should be the leftmost (most significant), the last byte (19) should be the rightmost (least significant). To place each byte in the right position, we shift it left by the right amount:
a 32-bit number looks like this in memory:
[byte 0] [byte 1] [byte 2] [byte 3]
bits bits bits bits
31-24 23-16 15-8 7-0
So 50 needs to land at bits 31-24, which means shifting it left by 24. ef needs bits 23-16, so shift by 16. And so on:
50 << 24 = 0x50000000
ef << 16 = 0x00ef0000
7f << 8 = 0x00007f00
19 << 0 = 0x00000019
Then we combine them into one number. Think of it like concatenation but for bits. 0x50000000 has 50 in the top byte and zeros everywhere else. 0x00ef0000 has ef in the second byte and zeros everywhere else. If you add them together (or OR them, same result when there's no overlap) you get:
0x50ef7f19 = 1357807385
One more thing: in the Rust code you'll see & 0x7f on the first byte. 0x7f in binary is 01111111, so it forces the highest bit of that byte to 0. That bit ends up as bit 31 of the final 32-bit number, and in languages like Java and C, bit 31 is the sign bit. If it's set, the number becomes negative, and a negative number mod 1000000 gives weird results. Rust's u32 is unsigned so it doesn't strictly matter here, but the RFC does it, so we do it
Step 4: Modulo 10^6:
1357807385 % 1000000 = 807385
Your TOTP code is 807385
Security Properties
The offset changes with every HMAC output, so you're picking a different 4-byte window each time. But the real security here isn't about hiding which bytes were used. The attacker knows the algorithm. The point is that a 6-digit code is one of a million possible values, extracted from a 32-bit number, extracted from 20 bytes of HMAC output. Way too much information is lost in the truncation and modulo to work backwards to the secret
Replay resistance is built into the time window. Each code is only valid for ~30 seconds, and the RFC says the verifier MUST NOT accept the same OTP twice. One use per code, even within the same time step
Stealing a code is nearly useless for future access. Knowing the current code tells you nothing about the next one. You'd need to reverse HMAC-SHA1 to recover the secret, and brute force is the best known attack against HOTP (per RFC 4226's security analysis)
What actually breaks TOTP? The secret getting compromised. Steal the shared secret and you can generate every future code forever. The RFC recommends tamper-resistant hardware for key storage and decrypting only at validation time. Don't screenshot your QR codes. The other failure mode is real-time phishing where an attacker proxies your code to the server immediately, but that's social engineering
A Working Rust Implementation
Here's the full thing. Compiles, runs, and we verify it against the RFC 6238 Appendix B test vectors to make sure we didn't mess anything up
use hmac::{Hmac, KeyInit, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
fn generate_totp(secret: &[u8], time_step: u64, t0: u64, unix_time: u64, digits: u32) -> u32 {
// T = floor((unix_time - T0) / X), RFC 6238 section 4.2
let counter = (unix_time - t0) / time_step;
// big-endian 8 bytes, u64 handles post-2038 timestamps
let counter_bytes = counter.to_be_bytes();
let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key size");
mac.update(&counter_bytes);
let result = mac.finalize().into_bytes();
// low 4 bits of last byte = offset (0..15), so we stay in bounds
let offset = (result[19] & 0x0f) as usize;
// 4 bytes at offset into a u32, top bit masked to avoid sign issues
let code = ((result[offset] as u32 & 0x7f) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
code % 10u32.pow(digits)
}
fn main() {
let secret = b"12345678901234567890"; // RFC 6238 test vector
// verify against RFC 6238 Appendix B (SHA1, 8-digit)
let test_vectors: &[(u64, u32)] = &[
(59, 94287082),
(1111111109, 07081804),
(1111111111, 14050471),
(1234567890, 89005924),
(2000000000, 69279037),
(20000000000, 65353130),
];
println!("RFC 6238 test vectors (SHA1, 8-digit):");
for (time, expected) in test_vectors {
let code = generate_totp(secret, 30, 0, *time, 8);
let pass = if code == *expected { "PASS" } else { "FAIL" };
println!(" T={:<12} expected={:08} got={:08} {}", time, expected, code, pass);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let code = generate_totp(secret, 30, 0, now, 6);
println!("\nYour TOTP code: {:06}", code);
println!("(valid for ~30 seconds)");
}
Output:
RFC 6238 test vectors (SHA1, 8-digit):
T=59 expected=94287082 got=94287082 PASS
T=1111111109 expected=07081804 got=07081804 PASS
T=1111111111 expected=14050471 got=14050471 PASS
T=1234567890 expected=89005924 got=89005924 PASS
T=2000000000 expected=69279037 got=69279037 PASS
T=20000000000 expected=65353130 got=65353130 PASS
Your TOTP code: 152174
(valid for ~30 seconds)
All six test vectors pass. That's the whole thing, a shared secret, the current time, HMAC-SHA1, a truncation, and modular arithmetic. No network calls, no state, no sessions. Both sides compute the same number independently, and if they match, you're in