Home › Security › Authentication & authorization
algorithm low

One-time codes (HOTP, TOTP)

Problem A service must confirm that someone holds a second proof besides their password, with nothing to carry beyond a phone or token, and must stop strangers from guessing or replaying the short code at the moment of login.

Short numeric codes derived from a shared secret plus a counter or the clock (HOTP, TOTP), or random codes sent by SMS or email; safe only with expiry, single use and attempt limits.

one HMAC-SHA-1 per candidate code; verify with a +-1 step window costs 3 HMACs, about 2.8 µs measured
otphotptotphmacrfc-4226rfc-6238sms-otpsecond-factor
Overview

A one-time code is a short number valid once. HOTP (RFC 4226) takes HMAC-SHA-1 of a shared secret and a counter and keeps 6-8 decimal digits; TOTP (RFC 6238) uses the clock divided into 30 s steps as the counter, so token and server agree with no message. Workforce VPNs, bank logins, game accounts, password managers and hardware tokens in plant networks use it. The other family is random codes sent by SMS or email, held server-side.

Key facts

Use when

  • A second factor with no hardware to buy: workforce VPN, banking, gaming, health portals.
  • Offline devices: a token or app needs no network.
  • Step-up for a risky action; SMS or email as fallback.

Avoid when

  • Users can be phished in real time: use passkeys.
  • SMS is the only channel for high-value accounts.
  • A transaction's contents must be bound: use OCRA (below).

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.

  1. Provision: the server draws K from a CSPRNG, shows it as base32 in an otpauth://totp/Issuer:account?secret=BASE32&issuer=Issuer URI inside a QR code, and activates it only after the user types one valid code.
  2. 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.
  3. 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).
  4. 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.
  5. 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).
  6. 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 rejected verify (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: 07081804 has 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

HOTPcounter-based, for tokens with a button and no clock; carries the resynchronisation burden.
OCRA (RFC 6287, OATH Challenge-Response Algorithm)HOTP with challenge and message inputs; binds a code to a transaction.
Passkeyspublic-key, origin-bound, not phishable; see MFA for combining factors.
Signed one-time linksemail flows without typing (MACs, JWT, replay window).

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

namelangrepoinstallverdictlicencehealthas_ofnotes
totp-rsRustconstantoine/totp-rscargo add totp-rs————TOTP with SHA-1/256/512, otpauth URI and QR, constant-time check
pyotpPythonpyauth/pyotppip install pyotp————HOTP, TOTP, valid_window
pquerna/otpGopquerna/otpgo get github.com/pquerna/otp————HOTP, TOTP, key generation, QR
google-authenticator-libpamCgoogle/google-authenticator-libpam—————PAM module for ssh/login with TOTP and rate limit
oath-toolkitCoath-toolkitbrew 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

Used by

mfa · plannedMulti-factor authentication flows: TOTP, push, WebAuthn second factor, recovery codes, step-up

Systems (7):

Alternatives and related

Passkeys (WebAuthn)Sites with consumer or employee logins suffering from phishing, leaked password databases and weak SMS or app-code second factors. This guide covers when and how to move login to passkeys.Message authentication codes (HMAC, CMAC, Poly1305, KMAC)A receiver gets bytes over a channel an attacker can modify (webhook, cookie, radio frame, download link) and must tell whether a holder of a shared secret produced them unchanged.JSON Web Token (JWT)A server must accept requests from clients or other services it holds no stored state for, and decide who sent them, for whom they are meant and until when, without a database lookup per request.Replay protection windowReceivers of authenticated messages (VPN packets, voice frames, sensor readings, signed API calls) must reject copies that an attacker or the network re-sends, though every copy carries a valid tag and messages may arrive out of order.
constant time code · plannedWriting secret-dependent code without branches or memory lookups on secrets; constant-time compare and select
csprng · plannedOS-backed cryptographically secure random numbers (getrandom), and when a fast PRNG is not enough
Secrets managementAny system with database passwords, API keys, signing keys or tokens must keep them out of code, logs and images and rotate them safely. This guide answers where secrets live and how workloads obtain them.Token bucketA service or network device must cap a client's average request rate while still letting idle clients send short bursts, for per-client limits and traffic shaping.Distributed rate limitingA fleet of servers behind a load balancer must hold each client, account or endpoint to one shared request quota, although no single server sees all of that client's traffic and counting every request centrally is slow.Password hashing (Argon2id)A service that stores user passwords must make a leaked database expensive to crack, since attackers can test billions of guesses per second on GPUs against ordinary hashes.Envelope encryptionServices storing sensitive data must encrypt it at rest, rotate keys and delete data reliably, without sending every byte through a key management service.
key rotation · plannedRotating keys without downtime: key ids, overlap windows, re-wrapping, JWKS publication, revocation