Deep dive
How it works
In: shared secret K (at least 128 bit), counter C or time, the typed code · Out: accept or reject · State: K, last accepted step or counter, failure count · Cost: 1 HMAC-SHA-1 per candidate, 3 with a +-1 step window.
- Provision: the server draws K from a CSPRNG, shows it as base32 in an
otpauth://totp/Issuer:account?secret=BASE32&issuer=IssuerURI inside a QR code, and activates it only after the user types one valid code. - Generate: H = HMAC-SHA-1(K, C as 8 bytes big-endian). Offset = low 4 bits of H[19]; take 4 bytes there, clear the top bit (31-bit integer), reduce mod 10^digits, zero-pad.
- TOTP sets C = T = floor((now - T0) / X) with T0 = 0 and X = 30 s by default. RFC 6238 also defines HMAC-SHA-256 and HMAC-SHA-512 (longer keys in its test vectors).
- Verify TOTP: for steps T-1..T+1 compute the code and compare in constant time. Accept the highest match whose step is above the stored last accepted step, then store it.
- Verify HOTP: server counter S; try S..S+s (look-ahead s); on a match set S to match + 1. Beyond s, ask for two consecutive codes (RFC 4226 section 7.4).
- Throttle per account across sessions: count failures, delay or lock (RFC 4226 section 7.3); reset only on success.
Random SMS or email codes need server state instead of a shared clock: 6+ digits from a CSPRNG (rejection sampling), a keyed hash stored with account, purpose and expiry, single use, capped attempts per account.
Code
Rust, std only: SHA-1, HMAC, HOTP and a TOTP verifier with a +-1 step window, last-step replay block and lockout. Asserts check RFC 4226 Appendix D and RFC 6238 Appendix B (SHA-1).
fn sha1(m: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
let mut d = m.to_vec();
d.push(0x80);
while d.len() % 64 != 56 { d.push(0); }
d.extend((m.len() as u64 * 8).to_be_bytes());
for c in d.chunks(64) {
let mut w = [0u32; 80];
for i in 0..16 { w[i] = u32::from_be_bytes(c[4 * i..4 * i + 4].try_into().unwrap()); }
for i in 16..80 { w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); }
let [mut a, mut b, mut cc, mut dd, mut e] = h;
for i in 0..80 {
let (f, k) = match i {
0..=19 => ((b & cc) | (!b & dd), 0x5A827999u32),
20..=39 => (b ^ cc ^ dd, 0x6ED9EBA1),
40..=59 => ((b & cc) | (b & dd) | (cc & dd), 0x8F1BBCDC),
_ => (b ^ cc ^ dd, 0xCA62C1D6),
};
let t = a.rotate_left(5).wrapping_add(f).wrapping_add(e).wrapping_add(k).wrapping_add(w[i]);
(e, dd, cc, b, a) = (dd, cc, b.rotate_left(30), a, t);
}
for (x, y) in h.iter_mut().zip([a, b, cc, dd, e]) { *x = x.wrapping_add(y); }
}
let mut out = [0u8; 20];
for (i, x) in h.iter().enumerate() { out[4 * i..4 * i + 4].copy_from_slice(&x.to_be_bytes()); }
out
}
fn hmac_sha1(key: &[u8], msg: &[u8]) -> [u8; 20] {
let mut k = [0u8; 64];
if key.len() > 64 { k[..20].copy_from_slice(&sha1(key)); } else { k[..key.len()].copy_from_slice(key); }
let mut inner: Vec<u8> = k.iter().map(|b| b ^ 0x36).collect();
inner.extend_from_slice(msg);
let mut outer: Vec<u8> = k.iter().map(|b| b ^ 0x5c).collect();
outer.extend_from_slice(&sha1(&inner));
sha1(&outer)
}
/// RFC 4226 section 5.3: dynamic truncation of HMAC-SHA-1(K, C).
fn hotp(key: &[u8], counter: u64, digits: u32) -> String {
let m = hmac_sha1(key, &counter.to_be_bytes());
let o = (m[19] & 0xf) as usize;
let bin = u32::from_be_bytes(m[o..o + 4].try_into().unwrap()) & 0x7fff_ffff;
format!("{:0w$}", bin % 10u32.pow(digits), w = digits as usize)
}
fn ct_eq(a: &str, b: &str) -> bool { // no early exit on content
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0
}
struct TotpVerifier { key: Vec<u8>, digits: u32, step: u64, last: Option<u64>, fails: u32, max_fails: u32 }
impl TotpVerifier {
fn verify(&mut self, now: u64, code: &str) -> bool {
if self.fails >= self.max_fails { return false; } // throttle: locked until reset out of band
let t = now / self.step;
let mut hit = None;
for s in t.saturating_sub(1)..=t + 1 { // drift window T-1..T+1, all three always computed
let fresh = self.last.map_or(true, |l| s > l); // replay block
if ct_eq(&hotp(&self.key, s, self.digits), code) & fresh { hit = Some(s); }
}
match hit {
Some(s) => { self.last = Some(s); self.fails = 0; true }
None => { self.fails += 1; false }
}
}
}
fn main() {
let k = b"12345678901234567890";
let want = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"];
for (c, w) in want.iter().enumerate() { assert_eq!(hotp(k, c as u64, 6), *w); } // RFC 4226 App. D
let mk = || TotpVerifier { key: k.to_vec(), digits: 8, step: 30, last: None, fails: 0, max_fails: 5 };
assert_eq!(hotp(k, 59 / 30, 8), "94287082"); // RFC 6238 App. B, SHA-1
let mut v = mk();
assert!(v.verify(59, "94287082"));
assert!(!v.verify(59, "94287082")); // same step: replay rejected
assert!(!v.verify(89, "94287082")); // in window, but step 1 <= last: rejected
assert!(mk().verify(89, "94287082")); // clock skew: the T-1 code is accepted
assert!(!mk().verify(59 + 90, "94287082")); // T=4, window 3..=5: out of window
let mut v = mk();
for _ in 0..5 { assert!(!v.verify(59, "00000000")); }
assert!(!v.verify(59, "94287082")); // locked after 5 failures, even with the right code
}
Complexity and constants
- Generate: 1 HMAC-SHA-1 of 8 bytes: 4 SHA-1 compressions with the key pads recomputed, 2 with cached pad states.
- Verify, window 1: 3 HMACs. Measured on an M4 Pro, release build, the code above: about 1.0 µs per
hotp, 2.8 µs per rejectedverify(heap copies, no cached pads). - Guess odds: a window of w steps leaves w valid codes of 10^6, so one guess wins with probability w/10^6; 5 failures per lockout cycle cap an attacker near 1.5e-5 per cycle (w = 3). HOTP look-ahead s gives s/10^6 per guess.
Efficiency
- Cost: about 2.8 µs CPU per verify (compute-bound); the real cost is one account-row read and write per attempt, and for SMS the per-message fee and carrier latency.
- Levers:
- Cache HMAC pad states per user — 2 of 4 compressions saved (see MACs) — high-rate verifiers.
- Check the failure counter before any HMAC — a locked account costs one read — under attack.
- Token bucket per account, shared via distributed rate limiting — stops cross-node parallel guessing — multi-node verifiers.
- Naive → standard → state of the art:
- Random code in a plain table, no expiry, per-IP limits · cheap, guessable
- TOTP, window 1, last step stored, per-account lockout · 3 HMACs and one row write per attempt
- Passkey assertion · one signature check, no shared secret, not relayable
Implementation notes
- Compare the code as a fixed-width string:
07081804has a leading zero that integers drop. - Check, last-step update and failure counter in one transaction (or compare-and-set on the last step): two parallel requests with one valid code must not both pass.
- Encrypt secrets at rest with an envelope key that is rotated; keep them out of logs and plain backups. Hashing like a password is impossible: the server needs the raw secret (secrets management holds the wrapping key).
- Authenticator apps expect base32 without padding. The
otpauth://Key Uri Format is a Google Authenticator wiki document, not an RFC; many apps ignore all but SHA-1, 6 digits, 30 s, so keep those defaults. - Recovery codes: random, single use, shown once, stored hashed (Argon2). Rate-limit "resend code" too: SMS pumping uses it.
Pitfalls
- No single-use rule: a code works twice inside its window, so a relayed one is reusable.
- Throttling by IP or session only: parallel sessions walk the 10^6 space; RFC 4226 wants limits across sessions.
- Wide window, large HOTP look-ahead or early-exit comparison: guess odds rise linearly; timing leaks a prefix match.
- Real-time phishing proxies relay the typed code; no TOTP parameter fixes this.
- SMS: SIM swap, number porting. NIST SP 800-63B-4 names the telephone network as its one restricted authenticator and asks for risk checks before sending.
- NIST SP 800-63B-4: out-of-band codes older than 10 minutes are invalid; time-based nonces change at least every 2 minutes.
Variants and alternatives
In production
- RFC 6238 apps and PAM modules guard workforce VPNs and servers, bank step-up and game accounts; password managers store TOTP seeds.
- Hardware tokens in industrial and government networks: HOTP, TOTP, some OCRA.
- Email and SMS codes: sign-up, password reset, delivery handover, telehealth check-in; valid for minutes.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| totp-rs | Rust | constantoine/totp-rs | cargo add totp-rs | — | — | — | — | TOTP with SHA-1/256/512, otpauth URI and QR, constant-time check |
| pyotp | Python | pyauth/pyotp | pip install pyotp | — | — | — | — | HOTP, TOTP, valid_window |
| pquerna/otp | Go | pquerna/otp | go get github.com/pquerna/otp | — | — | — | — | HOTP, TOTP, key generation, QR |
| google-authenticator-libpam | C | google/google-authenticator-libpam | — | — | — | — | — | PAM module for ssh/login with TOTP and rate limit |
| oath-toolkit | C | oath-toolkit | brew install oath-toolkit | — | — | — | — | oathtool, liboath, PAM module, HOTP/TOTP/OCRA |
Sources
- M'Raihi, Bellare, Hoornaert, Naccache, Ranen, HOTP: An HMAC-Based One-Time Password Algorithm, RFC 4226, 2005. rfc
- M'Raihi, Machani, Pei, Rydell, TOTP: Time-Based One-Time Password Algorithm, RFC 6238, 2011. rfc
- M'Raihi, Rydell, Bajaj, Machani, Naccache, OCRA: OATH Challenge-Response Algorithm, RFC 6287, 2011. rfc
- Google Authenticator project, Key Uri Format (wiki document, not a standard). wiki
- NIST, SP 800-63B-4 Digital Identity Guidelines: Authentication and Authenticator Management. spec