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.
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")
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:
- Take the last 4 bits of the last byte of the HMAC — this gives an offset
n(0-15). - Read 4 bytes starting at offset
n. - Mask the top bit (
& 0x7fffffff) to ensure a positive 31-bit integer. - Take
modulo 10^6to 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"
& 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"
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.
| Parameter | Default | Common Variants | RFC Reference |
|---|---|---|---|
| Hash algorithm | SHA-1 | SHA-256, SHA-512 | RFC 6238 §1.2 |
| Time step (period) | 30 seconds | 60 seconds (some enterprise) | RFC 6238 §5.2 |
| Digits | 6 | 8 (high-security) | RFC 4226 §5.3 |
| Secret length | 160 bits (20 bytes) | 256 bits (SHA-256), 512 bits (SHA-512) | RFC 4226 §4 |
4. TOTP vs HOTP: When to Use Which
| Feature | HOTP (RFC 4226) | TOTP (RFC 6238) |
|---|---|---|
| Counter source | Incremented per use | Derived from current time |
| Code validity | Valid until next code requested | Expires after 30 seconds |
| Server state | Must track counter per user | Stateless (only needs clock) |
| Sync issues | Counter desync if app generates without server | Clock drift, but self-corrects |
| Offline use | Yes — no clock needed | Requires accurate clock |
| Use case | Hardware tokens (YubiKey OTP) | Authenticator apps (Google, Authy) |
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
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
| Parameter | Purpose | Default |
|---|---|---|
secret | Base32-encoded shared secret (required) | — |
issuer | Account label shown in the app | — |
algorithm | HMAC hash: SHA1, SHA256, or SHA512 | SHA1 |
period | Time step in seconds | 30 |
digits | Code length: 6 or 8 | 6 |
For HOTP, the scheme is otpauth://hotp/... and includes a counter parameter instead of period.
7. Implementation Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Secret not Base32-decoded | Codes never match | Decode the Base32 string to bytes before passing to HMAC |
| Counter packed little-endian | Codes match at counter=0 but diverge | Use big-endian: struct.pack(">Q", counter) |
| Forgot to mask top bit | Negative code values in Java/C | Always & 0x7fffffff before modulo |
| String comparison instead of timing-safe | Vulnerable to timing attacks | Use hmac.compare_digest() or equivalent |
| Time in milliseconds | Codes change every 30,000 seconds | Use Unix seconds, not milliseconds |
| SHA-256 but app uses SHA-1 | Codes don't match Google Authenticator | Default 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);
}
9. RFC Test Vectors
RFC 4226 and 6238 define standard test vectors. Use these to validate your implementation:
| Counter / Time | HOTP (SHA-1) | TOTP (SHA-1) |
|---|---|---|
| 0 (T=59) | 754226 | 94287082 |
| 1 (T=1111111109) | 287082 | 07081804 |
| 2 (T=1111111111) | 359152 | 14050471 |
| 3 (T=1234567890) | 969429 | 89005924 |
| 4 (T=2000000000) | 338314 | 69279037 |
| 5 (T=20000000000) | 254676 | 65353130 |
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.