Imagine you're building a system where users need to authenticate themselves. The traditional approach is simple: username and password. But passwords have several fundamental problems:
Once an attacker has your password, they can impersonate you indefinitely—until you discover the breach and change it (if you even know it was compromised).
The core problem: How do we create a credential that's valid only once, or only for a short period of time?
This is the foundation of multi-factor authentication (MFA). Even if someone steals your password, they can't use it without also having access to your second factor—something that changes frequently and can't be easily reused.
So you start to think about coming up with a solution. You soon realize the system must have the following properties at the minimum:
Let's start with the simplest solution: one-time passwords.
The idea is straightforward:
While this works, you soon realize this has several problems:
We need something that works offline and doesn't require a round-trip to a server, and doesn't require the server to store any OTP state.
You think of this hard and soon enough you land on the insight: What if both the server and the client could independently generate the same password?
The first thought would be to generate one-time use values that are synced between client and server independently, without requiring network communication. If both sides can arrive at the same value at the same time, we've solved the synchronization problem.
The most straightforward approach might be: store a list of OTPs on both the server and client.
Server stores: [123456, 789012, 345678, ...]
Client stores: [123456, 789012, 345678, ...]
User logs in → Client sends first OTP → Server checks if it matches first in list
This approach doesn't scale and introduces more problems than it solves.
First thought: What if we use random numbers?
Client generates random: 42
Server generates random: 42 ← Wait, how does this work?
Random numbers don't work because there's no way for both sides to independently arrive at the same random value.
You might think: "What if we use a pseudo-random number generator with a seed? Both sides could use the same seed and get the same sequence!"
Both sides use seed = 12345
PRNG(seed) → 42
PRNG(seed) → 17
PRNG(seed) → 99
PRNGs don't solve the fundamental problem: you still need synchronized state, and they introduce additional security risks compared to cryptographic hash functions.
Okay, random doesn't work. What if we use a sequential counter?
First, let's try using just the counter value itself as the password:
Both sides start at counter = 0
Client increments: counter = 1, password = "1"
Server increments: counter = 1, password = "1" ✓
Counters alone don't work because they're predictable and lack security. But wait—what if we combine counters with hashing?
So you think: How can one be generated at random, yet deterministically?
Being a brilliant engineer, you think: "If nothing works, throw hashing at the problem!"
You know that for a given input, a hash function always produces the same value. This is deterministic. But if you just hash the same key over and over, you'd get the same password every time—essentially the same as a static password. That's not secure.
Then the insight hits: What if we generate a sequence of values, and both the client and server hash the same value in the sequence? They would both arrive at the same hash result!
But what should this sequence be? What values should we use?
You realize: What if we hash the counter value with a secret key? This would make the output unpredictable while still being deterministic!
Both sides start at counter = 0
Both sides know: secret_key = "my-secret-key"
Client increments: counter = 1, hash(secret_key, 1) → "123456"
Server increments: counter = 1, hash(secret_key, 1) → "123456" ✓
This seems promising! Both sides can maintain a counter and increment it, and the hash function ensures the output is random and unpredictable.
Before we move to time, let's explore the counter-based approach more deeply. This is the basis of HOTP (HMAC-based One-Time Password).
The idea is simple: each time we generate a password, we increment a counter. The password is generated from both the secret and the counter.
password = generate(secret_key, counter)
HMAC-SHA1(secret, counter)┌────────────────────────────────────────────────────────────────┐
│ HOTP Authentication Flow │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ │ Server │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ │ Both sides maintain counter │ │
│ │ (Initially synchronized) │ │
│ │ Client counter: 3 │ │
│ │ Server counter: 3 │ │
│ │ │ │
│ │ (Client generates 2 OTPs but │ │
│ │ doesn't use them - drift!) │ │
│ │ Client counter: 5 │ │
│ │ Server counter: 3 │ │
│ │ │ │
│ │ 1. Client increments counter │ │
│ │ counter = 5 + 1 = 6 │ │
│ │ │ │
│ │ 2. Hash and generate 6 digit │ │
│ │ number → "456789" │ │
│ │ │ │
│ │ 3. Display to user │ │
│ │ "456789" │ │
│ │ │ │
│ │ 4. User enters "456789" ──────>│ │
│ │ │ │
│ │ │ 5. Server checks │
│ │ │ counter 3, 4, 5, │
│ │ │ 6, 7 (look-ahead) │
│ │ │ │
│ │ │ 6. Finds match at │
│ │ │ counter = 6 │
│ │ │ → "456789" ✓ │
│ │ │ │
│ │ │ 7. Update server │
│ │ │ counter to 6 │
│ │ │ │
│ │ 8. Authentication success <────│ │
│ │ │ │
│ ┌─────────────────────────────────────┴───────────────────────┤
│ │ │
│ │ Problem: If client generates OTPs but doesn't use them, │
│ │ counters drift apart and sync is lost │
│ └─────────────────────────────────────────────────────────────┘
Counters have the same fundamental problem: they require state synchronization.
This is why we need a better solution—one that doesn't require maintaining synchronized counters.
Then you think: What is something that is constantly changing, but both the client and server can independently arrive at the correct value?
Time is constantly changing, but both the client and server can independently ask "What time is it right now?" and get (approximately) the same answer. No synchronization needed. No state to maintain. No communication required.
Current time: 10:30:45
Both sides hash: hash(secret, current_time) → "456789"
Time naturally provides the sequence we need, and it's automatically synchronized. However, we'll need to handle some practical issues with clock differences, which we'll explore later.
Here's the elegant solution: Use time instead of a counter!
Time is naturally synchronized (well, mostly—we'll handle clock skew). Both the server and client can independently calculate: "What time is it right now?" and use that as the variable.
This is TOTP (Time-based One-Time Password).
HMAC-SHA1(secret, current_time), then truncate to 6 digitsCurrent time: 2024-01-15 10:30:45 UTC
Unix timestamp: 1705315845
Both sides calculate:
HMAC-SHA1(secret, 1705315845) = [0x3f, 0x8a, 0x2c, ...]
(apply truncation algorithm)
Password: 456789
Both the client and server independently calculate the same password from the same secret and current time. No communication needed!
But wait—if we use the exact current time, the password changes every second! That's too frequent. Users need time to read and type the password. Also, we need passwords to expire after a reasonable period for security.
And there's another issue: when the client generates an OTP and sends it to the server, some time would have elapsed. The generated value at the server might be slightly off due to clock differences or network delay. How do you handle this?
Now you tinker with how the algorithm should work. You need three core components:
HMAC(secret, time_value) → truncate to 6 digitsHMAC(secret, time_value) → truncate to 6 digitsHere's the complete flow from initial handshake to OTP use:
┌─────────────────────────────────────────────────────────────────┐
│ Phase 1: Initial Handshake (One Time) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ │ Server │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ │ │ 1. Generate secret │
│ │ │ secret_key = │
│ │ │ "JBSWY3DPEHPK3PXP" │
│ │ │ │
│ │ │ 2. Store secret_key │
│ │ │ securely in DB │
│ │ │ │
│ │ │ 3. Encode as QR code │
│ │ │ or display for │
│ │ │ manual entry │
│ │ │ │
│ │ 4. User scans QR code or │ │
│ │ manually enters secret │ │
│ │ │ │
│ │ 5. Store secret_key securely │ │
│ │ on client device │ │
│ │ │ │
│ │ 6. Handshake complete ✓ │ │
│ │ Both sides now share │ │
│ │ the same secret_key │ │
│ │ │ │
│ └────────────────────────────────┴─────────────────────┘ |
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: OTP Generation (Client Side - Offline) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ Client │ │
│ └────┬─────┘ │
│ │ │
│ │ 1. User opens authenticator app │
│ │ │
│ │ 2. App retrieves stored secret_key │
│ │ │
│ │ 3. Get current Unix timestamp │
│ │ current_time = 1705315845 │
│ │ │
│ │ 4. Calculate time step │
│ │ T = current_time // 30 │
│ │ T = 56843861 │
│ │ │
│ │ 5. Generate 6-digit OTP │
│ │ OTP = "456789" │
│ │ │
│ │ 6. Display OTP to user │
│ │ "456789" (valid for ~30 seconds) │
│ │ │
│ └─────────────────────────────────────────────────────────┘|
└─────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ Phase 3: Authentication (User Login with OTP) │
├───────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client │ │ Server │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ │ 1. User enters username │ │
│ │ and password │ │
│ │ │ │
│ │ 2. User enters OTP from │ │
│ │ authenticator app │ │
│ │ OTP = "456789" │ │
│ │ │ │
│ │ 3. Send login request ────────>│ │
│ │ (username, password, OTP) │ │
│ │ │ │
│ │ │ 4. Verify username │
│ │ │ and password │
│ │ │ │
│ │ │ 5. Retrieve user's │
│ │ │ secret_key from │
│ │ │ database │
│ │ │ │
│ │ │ 6. Get current time │
│ │ │ current_time = │
│ │ │ 1705315845 │
│ │ │ │
│ │ │ 7. Calculate time │
│ │ │ step T │
│ │ │ T = 56843861 │
│ │ │ │
│ │ │ 8. Check time window │
│ │ │ (T-1, T, T+1) │
│ │ │ to handle clock │
│ │ │ skew │
│ │ │ │
│ │ │ 9. For each T in │
│ │ │ window: │
│ │ │ - Calculate HMAC │
│ │ │ - Convert to 6 │
│ │ │ digits │
│ │ │ - Compare with │
│ │ │ user's OTP │
│ │ │ │
│ │ │ 10. Match found! ✓ │
│ │ │ OTP is valid │
│ │ │ │
│ │ 11. Authentication success <───│ │
│ │ User logged in │ │
│ │ │ │
│ └────────────────────────────────┴──────────────────────┤
│ │
│ Note: OTP expires after time step (30 seconds). Server │
│ accepts OTPs from T-1, T, and T+1 to handle clock │
│ skew and network delays. │
└───────────────────────────────────────────────────────────────┘
You realize there are two problems to solve:
You realize: What if we divide time into steps? Instead of using the exact current time, we divide time into intervals (time steps). The password stays the same for the duration of each step, then changes to a new value.
For example, with a 30-second time step:
This gives users enough time to read and enter the password, while still keeping it secure with regular expiration.
Now the algorithm becomes:
T = floor((current_time - T0) / X)T0 is the Unix epoch (January 1, 1970, 00:00:00 UTC)X is the time step (typically 30 seconds)HMAC-SHA1(secret, T), then truncate to 6 digitsCurrent time: 2024-01-15 10:30:45 UTC
Unix timestamp: 1705315845
Time step (X): 30 seconds
T0: 0 (Unix epoch)
T = floor((1705315845 - 0) / 30) = 56843861
HMAC-SHA1(secret, 56843861) = [0x3f, 0x8a, 0x2c, ...]
(apply truncation algorithm)
Password: 456789
This password is valid from 10:30:30 to 10:31:00.
So you think: how would you handle this? Clearly there would be some sync delay. The time step helps, but we need an additional mechanism.
Instead of only accepting the exact current time step, accept a small window around it. This accounts for:
Instead of only accepting the current time step (T), accept:
T-1) - in case client clock is slightly behind or there's network delayT) - the ideal caseT+1) - in case client clock is slightly aheadThis gives us a buffer window that handles typical clock skew and network delays while maintaining security. The window size is configurable—typically ±1 time step, which with a 30-second time step gives us a 90-second acceptance window (3 × 30 seconds).
Now the verification process becomes:
HMAC(secret, current_time_step) → truncate to 6 digitsHMAC(secret, current_time_step) → truncate to 6 digitsBeing a great engineer, you decide to make things flexible. What if different people need different algorithms or different digit lengths?
This flexibility allows the system to adapt to different security requirements and use cases.
Here's the complete TOTP algorithm:
import hmac
import hashlib
import time
import struct
def convert_to_digits(hmac_result, digits=6):
"""
Convert HMAC result to a specified number of digits.
Args:
hmac_result: HMAC digest (bytes)
digits: Number of digits desired (default 6)
Returns:
OTP as string with specified number of digits
"""
# Dynamic truncation algorithm
offset = hmac_result[-1] & 0x0F # Last 4 bits
binary = struct.unpack('>I', hmac_result[offset:offset+4])[0]
binary = binary & 0x7FFFFFFF # Mask MSB
# Convert to digits
otp = binary % (10 ** digits)
return str(otp).zfill(digits)
def generate_totp(secret_key, time_step=30, digits=6):
"""
Generate a TOTP password.
Args:
secret_key: Shared secret (bytes)
time_step: Time step in seconds (default 30)
digits: Number of digits in password (default 6)
Returns:
TOTP password as string
"""
# Calculate current time step
current_time = int(time.time())
T = current_time // time_step
# Convert T to bytes (8 bytes, big-endian)
T_bytes = struct.pack('>Q', T)
# Calculate HMAC-SHA1
hmac_result = hmac.new(secret_key, T_bytes, hashlib.sha1).digest()
# Convert to digits
return convert_to_digits(hmac_result, digits)
def verify_totp(secret_key, user_input, time_step=30, digits=6, window=1):
"""
Verify a TOTP password with clock skew tolerance.
Args:
secret_key: Shared secret (bytes)
user_input: Password entered by user
time_step: Time step in seconds (default 30)
digits: Number of digits in password (default 6)
window: Number of time steps to check on each side (default 1)
Returns:
True if valid, False otherwise
"""
current_time = int(time.time())
current_T = current_time // time_step
# Check current time step and window around it
for i in range(-window, window + 1):
T = current_T + i
T_bytes = struct.pack('>Q', T)
hmac_result = hmac.new(secret_key, T_bytes, hashlib.sha1).digest()
otp = convert_to_digits(hmac_result, digits)
if otp == user_input:
return True
return False
While TOTP is elegant, it's not without its challenges. Let's explore the downsides and important implementation details.
The security of TOTP depends critically on how the server stores the secret keys. Here are the best practices:
❌ Bad: Storing plaintext
Database: user_id=123, totp_secret="JBSWY3DPEHPK3PXP"
✅ Good: Encrypted storage
Database: user_id=123, totp_secret_encrypted="aGVsbG8gd29ybGQ="
Encryption key: Stored in secure key management service
# Server-side secret storage (pseudocode)
def store_totp_secret(user_id, secret):
# Encrypt the secret using a key from key management service
encryption_key = get_key_from_kms()
encrypted_secret = encrypt(secret, encryption_key)
# Store encrypted secret in database
database.store(user_id, encrypted_secret)
# Never log the plaintext secret
log.info(f"Stored encrypted TOTP secret for user {user_id}")
def retrieve_totp_secret(user_id):
# Retrieve encrypted secret from database
encrypted_secret = database.retrieve(user_id)
# Decrypt using key from key management service
encryption_key = get_key_from_kms()
secret = decrypt(encrypted_secret, encryption_key)
return secret
Remember: The security of TOTP is only as strong as the security of the secret storage mechanism. A compromised database with plaintext secrets renders the entire TOTP system useless.
We've journeyed from:
TOTP is a beautiful example of solving a complex security problem with elegant simplicity. By using time as a naturally synchronized variable, we've created a system that:
The next time you use Google Authenticator, Authy, or any TOTP app, you're witnessing this elegant solution in action. A shared secret, the current time, and a cryptographic hash function—that's all it takes to create a secure, one-time password system.