TOTP & HOTP from Scratch — How 2FA Codes Really Work

Every time you open Google Authenticator and see a 6-digit code that changes every 30 seconds, you're looking at a TOTP — a Time-based One-Time Password defined in RFC 6238. Its sibling HOTP (RFC 4226) uses a counter instead of time. Both are built on HMAC-SHA1 and a clever truncation trick that turns a 20-byte hash into 6 readable digits. This guide walks through the algorithm step by step, with working code you can verify against the OTP Calculator.

Follow along: The HOTP & TOTP Calculator generates real codes in your browser — enter a Base32 secret and see the exact same 6-digit output as Google Authenticator.

1. The Shared Secret: Base32 Encoding

Before any code is generated, the server and the authenticator app share a secret — a random byte string, typically 20 bytes (160 bits). To make it easy for humans to type or scan as a QR code, the secret is encoded in Base32:

# RFC 4238 test secret (20 bytes, Base32-encoded)
secret_bytes = b"12345678901234567890"       # raw ASCII
secret_b32   = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"  # Base32

# Decoding in Python
import base64, hmac, hashlib, struct, time

def decode_secret(b32_string):
    # Remove spaces, pad to multiple of 8
    s = b32_string.replace(" ", "").upper()
    padding = (8 - len(s) % 8) % 8
    s += "=" * padding
    return base64.b32decode(s)

key = decode_secret("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
Why Base32 and not Base64? Base32 uses only A-Z and 2-7 — no ambiguous characters like 0/O or 1/l/I. It's case-insensitive and easy to type on a phone keyboard. The trade-off is longer strings (a 20-byte secret becomes 32 characters).

2. HOTP: The Counter-Based Algorithm (RFC 4226)

HOTP was defined by OATH in RFC 4226. The core idea: compute HMAC-SHA1(secret, counter), then extract 6 digits from the result.

Step 1 — Pack the Counter as 8 bytes

The counter is a 64-bit integer, packed big-endian into exactly 8 bytes:

counter = 0  # starts at 0, increments each use
counter_bytes = struct.pack(">Q", counter)   # b'\x00\x00\x00\x00\x00\x00\x00\x00'

Step 2 — HMAC-SHA1

Compute HMAC-SHA1 of the counter using the shared secret as key:

hmac_result = hmac.new(key, counter_bytes, hashlib.sha1).digest()
# 20 bytes, e.g.: 75a48a19d4cbe100644e8ac1397eea945a90b6e7

Step 3 — Dynamic Truncation

This is the clever part. We need to extract a 6-digit decimal number from a 20-byte binary hash. The algorithm:

  1. Take the last 4 bits of the last byte of the HMAC — this gives an offset n (0-15).
  2. Read 4 bytes starting at offset n.
  3. Mask the top bit (& 0x7fffffff) to ensure a positive 31-bit integer.
  4. Take modulo 10^6 to get 6 digits.
offset = hmac_result[-1] & 0x0F          # 4-bit offset, 0-15
binary = hmac_result[offset:offset+4]   # 4 bytes at offset
code_int = struct.unpack(">I", binary)[0]
code_int = code_int & 0x7fffffff         # mask top bit → 31-bit positive
code = code_int % 1000000                # 6-digit decimal
otp = str(code).zfill(6)                 # zero-pad: "000418"
Why mask the top bit? Without the & 0x7fffffff mask, the 4-byte integer could be negative in languages with signed integers (Java, C). The mask ensures the value is always positive and the modulo result is deterministic across all platforms.

Full HOTP Function

def hotp(key, counter, digits=6):
    counter_bytes = struct.pack(">Q", counter)
    h = hmac.new(key, counter_bytes, hashlib.sha1).digest()
    offset = h[-1] & 0x0F
    code = struct.unpack(">I", h[offset:offset+4])[0] & 0x7fffffff
    return str(code % (10 ** digits)).zfill(digits)

# RFC 4226 test vector
assert hotp(key, 0) == "754226"
assert hotp(key, 1) == "287082"
assert hotp(key, 5) == "254676"
Verify this now: Open the OTP Calculator, enter secret GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ, set counter to 0, and check if the output is 754226.

3. TOTP: Adding Time Steps (RFC 6238)

TOTP is a thin wrapper around HOTP — it replaces the counter with a time step:

def totp(key, timestamp=None, period=30, digits=6):
    if timestamp is None:
        timestamp = int(time.time())
    counter = timestamp // period       # current time step
    return hotp(key, counter, digits)

That's the entire RFC 6238. The counter becomes floor(Unix_time / 30), so every 30 seconds a new code is generated. No server round-trip needed — both sides compute the same code independently as long as their clocks are within the same 30-second window.

ParameterDefaultCommon VariantsRFC Reference
Hash algorithmSHA-1SHA-256, SHA-512RFC 6238 §1.2
Time step (period)30 seconds60 seconds (some enterprise)RFC 6238 §5.2
Digits68 (high-security)RFC 4226 §5.3
Secret length160 bits (20 bytes)256 bits (SHA-256), 512 bits (SHA-512)RFC 4226 §4

4. TOTP vs HOTP: When to Use Which

FeatureHOTP (RFC 4226)TOTP (RFC 6238)
Counter sourceIncremented per useDerived from current time
Code validityValid until next code requestedExpires after 30 seconds
Server stateMust track counter per userStateless (only needs clock)
Sync issuesCounter desync if app generates without serverClock drift, but self-corrects
Offline useYes — no clock neededRequires accurate clock
Use caseHardware tokens (YubiKey OTP)Authenticator apps (Google, Authy)
Most apps use TOTP. Google Authenticator, Microsoft Authenticator, Authy, and 1Password all implement TOTP. HOTP is mostly seen in hardware tokens where a battery-backed counter is more reliable than a real-time clock.

5. Clock Drift and the Grace Window

No two clocks tick in perfect sync. If the user's phone is 35 seconds ahead of the server, their time step number differs, and the code won't match. The standard solution is a grace window:

def verify_totp(key, user_code, timestamp=None, period=30, window=1):
    """Check code against current step ± window steps."""
    if timestamp is None:
        timestamp = int(time.time())
    current_step = timestamp // period

    for offset in range(-window, window + 1):
        step = current_step + offset
        expected = hotp(key, step)
        if hmac.compare_digest(expected, user_code):
            return True, offset  # also return drift for resync
    return False, None

# With window=1, codes from the previous 30s and next 30s are accepted
# Some servers use window=2 (±60s) for better UX
Security trade-off: Each step of window expansion multiplies the attack surface by 3x (previous, current, next). A window of 1 means 3 valid codes at any moment — still very safe given the 1-in-1,000,000 odds per code, but avoid going beyond 2.

6. QR Code Provisioning: The otpauth URI

When you scan a QR code to add an account to Google Authenticator, the code encodes a URL in this format:

otpauth://totp/CardWise:[email protected]?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=CardWise&algorithm=SHA1&period=30&digits=6
ParameterPurposeDefault
secretBase32-encoded shared secret (required)
issuerAccount label shown in the app
algorithmHMAC hash: SHA1, SHA256, or SHA512SHA1
periodTime step in seconds30
digitsCode length: 6 or 86

For HOTP, the scheme is otpauth://hotp/... and includes a counter parameter instead of period.

7. Implementation Pitfalls

PitfallSymptomFix
Secret not Base32-decodedCodes never matchDecode the Base32 string to bytes before passing to HMAC
Counter packed little-endianCodes match at counter=0 but divergeUse big-endian: struct.pack(">Q", counter)
Forgot to mask top bitNegative code values in Java/CAlways & 0x7fffffff before modulo
String comparison instead of timing-safeVulnerable to timing attacksUse hmac.compare_digest() or equivalent
Time in millisecondsCodes change every 30,000 secondsUse Unix seconds, not milliseconds
SHA-256 but app uses SHA-1Codes don't match Google AuthenticatorDefault to SHA-1 for compatibility unless you control both sides

8. JavaScript Implementation

// Browser-side TOTP using Web Crypto API
async function computeTOTP(base32Secret, period = 30, digits = 6) {
  // 1. Decode Base32 secret
  const key = base32Decode(base32Secret);

  // 2. Compute time step
  const counter = Math.floor(Date.now() / 1000 / period);
  const counterBytes = new ArrayBuffer(8);
  new DataView(counterBytes).setBigUint64(0, BigInt(counter));

  // 3. HMAC-SHA1
  const cryptoKey = await crypto.subtle.importKey(
    "raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]
  );
  const hmacResult = new Uint8Array(
    await crypto.subtle.sign("HMAC", cryptoKey, counterBytes)
  );

  // 4. Dynamic truncation
  const offset = hmacResult[hmacResult.length - 1] & 0x0F;
  const view = new DataView(hmacResult.buffer);
  let code = view.getUint32(offset) & 0x7fffffff;
  return String(code % (10 ** digits)).padStart(digits, "0");
}

// Base32 decoder
function base32Decode(s) {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const clean = s.replace(/[=\s]/g, "").toUpperCase();
  let bits = 0, value = 0, out = [];
  for (const ch of clean) {
    value = (value << 5) | alphabet.indexOf(ch);
    bits += 5;
    if (bits >= 8) { out.push((value >> (bits - 8)) & 0xff); bits -= 8; }
  }
  return new Uint8Array(out);
}
No external dependencies needed. The OTP Calculator tool uses exactly this approach — the Web Crypto API provides native HMAC-SHA1 in the browser, so there's no JavaScript crypto library to load.

9. RFC Test Vectors

RFC 4226 and 6238 define standard test vectors. Use these to validate your implementation:

Counter / TimeHOTP (SHA-1)TOTP (SHA-1)
0 (T=59)75422694287082
1 (T=1111111109)28708207081804
2 (T=1111111111)35915214050471
3 (T=1234567890)96942989005924
4 (T=2000000000)33831469279037
5 (T=20000000000)25467665353130
Note: The TOTP column uses 8 digits as per RFC 6238 test vectors. Most production systems use 6 digits. The secret for all test vectors is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ (Base32 of "12345678901234567890").

10. FAQ

What is the difference between TOTP and HOTP?

HOTP (RFC 4226) uses a counter that increments each time a code is generated, while TOTP (RFC 6238) replaces the counter with the current time divided into 30-second steps. TOTP is event-independent (codes expire automatically), while HOTP codes remain valid until the next one is requested.

Why are TOTP codes 6 digits?

RFC 4226 specifies a 6-digit truncation as the default because it balances usability (easy to type) with security (1 in 1,000,000 chance of guessing). The algorithm extracts a 31-bit value from the HMAC output, then takes modulo 10^6 to produce 6 digits. Some implementations use 8 digits for higher security.

What hash algorithm does TOTP use?

TOTP is defined with HMAC-SHA1 as the default, but RFC 6238 also allows HMAC-SHA256 and HMAC-SHA512. SHA1 is most common because Google Authenticator and similar apps historically only support SHA1. The shared secret is typically encoded in Base32.

How does clock drift affect TOTP?

TOTP divides Unix time by 30 seconds to get the time step. If the client and server clocks differ by more than 30 seconds, the codes won't match. Servers typically allow a grace window of plus or minus 1-2 time steps (30-90 seconds) and may implement resynchronization by checking previous and next steps.