You open Google Authenticator on your phone. A 6-digit code appears next to your account name. A countdown timer ticks toward zero. When it expires, a new code replaces it. This looks like magic, but the math behind it is straightforward. Both your phone and the server independently compute the same code from two inputs: a shared secret and the current time. No network communication happens during code generation, your phone does not call the server to get the code.
The core algorithm
TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. stands for Time-based One-Time Password. It is defined in RFC 6238 and builds on HOTP (HMACWhat is hmac?A keyed hash function that produces a fixed-length digest from a message and a secret key - used in webhook signatures and JWT signing.-based One-Time Password, RFC 4226). The algorithm works in four steps:
- Get the current time: take the Unix timestamp (seconds since January 1, 1970)
- Divide by the time step: divide the timestamp by 30 (the standard time step) and floor the result. This gives you a counter that changes every 30 seconds.
- Compute the HMAC: calculate HMAC-SHA1 using the shared secret as the key and the counter as the message
- Truncate to 6 digits: extract a 4-byte section from the HMAC output and convert it to a 6-digit number
// Simplified TOTP generation (conceptual, not production-ready)
function generateTOTP(secret, timeStep = 30) {
const counter = Math.floor(Date.now() / 1000 / timeStep);
const hmac = crypto.createHmac('sha1', secret)
.update(Buffer.from(counter.toString(16).padStart(16, '0'), 'hex'))
.digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1000000;
return code.toString().padStart(6, '0');
}The key insight is that both the server and the authenticator app have the same secret and the same clock. They independently compute the same code. No communication is needed.
Why HMAC-SHA1?
HMAC-SHA1 is a keyed hash function. Given the same key (the shared secret) and the same message (the time counter), it always produces the same output. But given a slightly different input, the output is completely different. An attacker who sees one code cannot predict the next one without knowing the secret.
While SHA-1 has known collision weaknesses, HMAC-SHA1 remains secure for TOTP because the attack surface is different. TOTP does not rely on collision resistance, it relies on the HMAC construction, which is still considered safe. That said, RFC 6238 also supports HMAC-SHA256 and HMAC-SHA512 for applications that want stronger algorithms.
The enrollment flow
Before a user can generate TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. codes, the server and the authenticator app need to share a secret. This happens once during enrollment:
- Server generates a random secret: typically 20 bytes, encoded as base32 (e.g.,
JBSWY3DPEHPK3PXP) - Server creates a provisioning URI:
otpauth://totp/MyApp:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30 - Server encodes the URI as a QR code: displayed to the user on screen
- User scans the QR code: the authenticator app reads the URI and stores the secret locally
- User enters a verification code: the server asks for a current TOTP code to confirm the setup worked
- Server stores the secret: associated with the user account, encrypted at restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources.
| Enrollment step | Who does it | What can go wrong |
|---|---|---|
| Generate secret | Server | Weak random number generator produces predictable secrets |
| Display QR code | Server to User | QR code cached or logged, secret exposed |
| Scan QR code | User to Authenticator app | User screenshots QR code and stores it insecurely |
| Verify code | User to Server | Server does not verify, enrollment succeeds without confirmation |
| Store secret | Server | Secret stored in plain text in the database |
Step 5 is critical and frequently missing. If you skip verification, a user might scan the wrong QR code, mistype the secret, or have a misconfigured authenticator app. They will not discover the problem until they try to log in later and cannot generate a valid code. Always verify during enrollment.
The 30-second window and time drift
TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. codes change every 30 seconds. But what happens if the server's clock says 10:00:00 and the user's phone says 10:00:02? They compute the same counter (same 30-second window), so the code matches. But what if the phone's clock is off by 45 seconds? They are in different windows, and the code does not match.
The solution is to accept codes from adjacent time windows. Most implementations accept the current window plus one window before and one after, a 90-second total acceptance window:
// TOTP verification with time drift tolerance
function verifyTOTP(secret, userCode, drift = 1) {
const currentCounter = Math.floor(Date.now() / 1000 / 30);
for (let i = -drift; i <= drift; i++) {
const counter = currentCounter + i;
const expectedCode = generateTOTPFromCounter(secret, counter);
if (expectedCode === userCode) {
return { valid: true, drift: i };
}
}
return { valid: false };
}The drift parameter controls how many adjacent windows to accept. A drift of 1 means three windows (previous, current, next). A drift of 2 means five windows. Larger drift increases tolerance for clock skew but also increases the window of vulnerability for replay attacks.
Preventing replay attacks
If a user enters code 123456 and it is valid, can an attacker who intercepted that code reuse it within the same 30-second window? Yes, unless you prevent it. The defense is to record the last successfully used counter and reject any counter less than or equal to it:
// Replay prevention: track last used counter
async function verifyWithReplayProtection(userId, secret, userCode) {
const lastCounter = await db.getLastUsedCounter(userId);
const currentCounter = Math.floor(Date.now() / 1000 / 30);
for (let i = -1; i <= 1; i++) {
const counter = currentCounter + i;
if (counter <= lastCounter) continue; // Reject replayed codes
const expectedCode = generateTOTPFromCounter(secret, counter);
if (expectedCode === userCode) {
await db.setLastUsedCounter(userId, counter);
return true;
}
}
return false;
}Popular authenticator apps
| App | Platform | Key features | Backup and sync |
|---|---|---|---|
| Google Authenticator | iOS, Android | Simple, widely recognized | Cloud backup via Google account |
| Authy | iOS, Android, Desktop | Multi-device sync, encrypted backups | Cloud backup via Authy account |
| 1Password | All platforms | Integrated with password manager | Synced across all 1Password devices |
| Microsoft Authenticator | iOS, Android | Push notifications for Microsoft accounts | Cloud backup via Microsoft account |
| Bitwarden Authenticator | All platforms | Open source, integrated with vault | Synced with Bitwarden vault |
The choice of authenticator app is the user's decision, not yours. Your server implements the standard TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. protocolWhat is protocol?An agreed-upon set of rules for how two systems communicate, defining the format of messages and the expected sequence of exchanges., and any compliant app works. Do not tie your implementation to a specific app.
Storing TOTPWhat is totp?Time-based One-Time Password - a 6-digit code generated from a shared secret and the current time, changing every 30 seconds. Powers authenticator apps. secrets securely
The TOTP shared secret is equivalent to a password, anyone who has it can generate valid codes. Storing it in plain text in your database means a database breach compromises every user's 2FAWhat is 2fa?Two-factor authentication - requiring a second verification step (like a phone code) on top of your password to prove it's really you..
Encrypt TOTP secrets at restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. using an application-level encryptionWhat is encryption?Scrambling data so only someone with the right key can read it, protecting information from being intercepted or stolen. key stored outside the database (in a secret manager like AWS Secrets Manager or HashiCorp Vault). This way, a database dump alone does not reveal the secrets.
// Encrypt before storing
const encryptedSecret = encrypt(totpSecret, process.env.TOTP_ENCRYPTION_KEY);
await db.saveTotpSecret(userId, encryptedSecret);
// Decrypt when verifying
const encryptedSecret = await db.getTotpSecret(userId);
const totpSecret = decrypt(encryptedSecret, process.env.TOTP_ENCRYPTION_KEY);
const isValid = verifyTOTP(totpSecret, userCode);