How TOTP Works: The Algorithm Behind Your Authenticator Codes
You tap a 6-digit code from an authenticator app a few times a week, and somehow the same code shows up on your phone and matches what the server expects, even though the two never talk to each other. So how does TOTP work? The same shared secret produces a fresh number every 30 seconds, and the answer is a small, deterministic algorithm that both sides run on their own. Nothing about the code crosses the network, and no central server hands it out.
TOTP (Time-based One-Time Password), defined in RFC 6238, turns a shared secret plus the current time into a short numeric code by running an HMAC over the time and truncating the result. Two-factor authentication (2FA) works because both sides can compute the same value without exchanging it, so the algorithm carries the entire trust model.
The sections below trace the algorithm with concrete numbers, then cover the half most explainers skip: how a server actually verifies a code, and a straight account of what 2FA stops and what it doesn’t. You can compute a live code in our TOTP generator as you read.
What Is TOTP, Really?
At its simplest, TOTP combines a shared secret with the current time to produce a short code that rotates on a fixed interval. Both the authenticator app and the server hold the same secret, read the same clock, and run the same math, so they arrive at the same code without ever transmitting it.
That non-transmission is the heart of it. The code is never sent during setup, only the secret is, and after that each side derives codes on its own. There is nothing on the wire to intercept except the secret at enrollment time and the 6 digits the user types at login. Three inputs collapse into one output:
| Input | Role | Typical value |
|---|---|---|
| Shared secret | The long-lived key, agreed once at enrollment | JBSWY3DPEHPK3PXP (Base32) |
| Time step | The counter that ticks forward | 30-second window |
| Output | The short code derived from the two | 324550 |
The secret is almost always written in Base32 (the letters A–Z and digits 2–7) because that alphabet is case-insensitive and survives being printed, typed, or packed into a QR code. You enroll a secret by scanning an otpauth:// URI, which you can render as an authenticator QR, or by typing the Base32 string by hand.
TOTP vs HOTP vs SMS vs Passkeys: the 2FA Landscape
TOTP is one option among several, and choosing well means knowing what the others give up. One relationship is worth memorizing: TOTP is HOTP with the counter replaced by the number of time steps since the Unix epoch. The rest is a trade-off between phishing resistance, convenience, and the infrastructure you have to run.
| Mechanism | Driver | Code lifetime | Phishing-resistant? | Needs network? | Typical use |
|---|---|---|---|---|---|
| HOTP (RFC 4226) | Incrementing counter | Until used | No | No | Hardware tokens, legacy |
| TOTP (RFC 6238) | Current time | ~30 seconds | No | No (after enrollment) | Authenticator apps |
| SMS OTP | Server sends a code | A few minutes | No | Yes (cellular) | Consumer fallback |
| Push approval | Server prompt to a device | Per request | Partially | Yes | App-based 2FA |
| Passkey / FIDO2 | Public-key challenge | Per request | Yes (origin-bound) | Yes | Modern accounts |
Each row trades something away. TOTP and HOTP run offline once enrolled, which makes them resilient and private, but neither resists phishing on its own: a convincing fake page can ask for the code and relay it. SMS adds a network channel that brings its own attack surface. Passkeys close the phishing gap by binding the credential to the site’s origin, which is where the industry is heading. TOTP is strong, available everywhere, and free, and that combination is why it stays so common.
How the TOTP Algorithm Works, Step by Step
The algorithm is four steps. Each one below runs against the RFC test secret JBSWY3DPEHPK3PXP and a fixed Unix time of 1700000000, so every number is reproducible.
- Decode the Base32 secret to raw key bytes.
- Compute the time-step counter from the current Unix time.
- HMAC the counter with the secret key.
- Truncate the digest down to a 6-digit code.
Step 1 — Decode the Base32 secret to bytes
Base32 packs 5 bits into each character. The decoder groups the characters back into 8-bit bytes. The secret JBSWY3DPEHPK3PXP decodes to the 10 raw bytes 48 65 6c 6c 6f 21 de ad be ef. This byte array, not the printable string, is the HMAC key.
Step 2 — Compute the time-step counter
The counter is the number of whole time steps that have elapsed since a starting point: T = floor((unixTime − T0) / period). The RFC defaults are T0 = 0 (the Unix epoch) and period = 30. With unixTime = 1700000000, that gives T = floor(1700000000 / 30) = 56666666. This integer is then encoded as an 8-byte big-endian value: 00 00 00 00 03 60 aa 2a. Because the counter only changes when a new 30-second window begins, every code is stable for the length of one window and then jumps.
Step 3 — HMAC the counter with the secret
The algorithm runs HMAC-SHA1 over the 8-byte counter using the secret bytes as the key. HMAC is a keyed, one-way function: without the secret you cannot reverse the digest or forge a valid one, which is what makes the code unforgeable. For our inputs the digest is the 20 bytes 1d 70 6e 94 1a c7 6b 6d 4a 46 dd 6f af a4 5f e3 35 11 bf 86.
Step 4 — Dynamic truncation to a 6-digit code (RFC 4226)
A 20-byte digest is too long to type, so RFC 4226’s dynamic truncation extracts a number from it. Take the low nibble of the last byte as an offset: the last byte is 0x86, its low nibble is 6, so the offset is 6. Read 4 bytes starting at that offset (6b 6d 4a 46), mask off the top bit of the first to keep the number positive, and you get the integer 1802324550. Reduce it modulo 10^6 and zero-pad: 1802324550 % 1000000 = 324550. That is the code your app shows for this secret at this instant.
The same algorithm in JavaScript, using the browser’s native Web Crypto API and no dependencies. Each comment maps a block to one of the four steps above:
// TOTP per RFC 6238 — SHA-1, 6 digits, 30s period (the defaults).
async function generateTotp(base32Secret, unixTime = Date.now() / 1000) {
// Step 1: decode the Base32 secret (A-Z, 2-7) to raw key bytes.
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const ch of base32Secret.replace(/=+$/, '').toUpperCase()) {
bits += alpha.indexOf(ch).toString(2).padStart(5, '0');
}
const keyBytes = new Uint8Array(
bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
// Step 2: counter = number of 30s steps since the epoch (8-byte big-endian).
let counter = Math.floor(unixTime / 30);
const msg = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
msg[i] = counter & 0xff;
counter = Math.floor(counter / 256);
}
// Step 3: HMAC-SHA1 the counter with the secret key.
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
// Step 4: dynamic truncation (RFC 4226) -> 6-digit code.
const offset = hmac[hmac.length - 1] & 0x0f;
const binary = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) | hmac[offset + 3];
return (binary % 1_000_000).toString().padStart(6, '0');
}
const code = await generateTotp('JBSWY3DPEHPK3PXP', 1700000000);
console.log(code); // -> "324550"
The same algorithm in Python, using only the standard library (hmac and struct):
import base64, hmac, hashlib, struct, time
def totp(secret, for_time=None, period=30, digits=6, digest='sha1'):
if for_time is None:
for_time = time.time()
# Step 1: Base32-decode the secret to raw key bytes.
key = base64.b32decode(secret.upper())
# Step 2: counter = number of time steps since the epoch (8-byte big-endian).
counter = int(for_time // period)
msg = struct.pack(">Q", counter)
# Step 3: HMAC the counter with the secret.
h = hmac.new(key, msg, digest).digest()
# Step 4: dynamic truncation (RFC 4226) -> N-digit code.
offset = h[-1] & 0x0f
binary = ((h[offset] & 0x7f) << 24 |
(h[offset + 1] & 0xff) << 16 |
(h[offset + 2] & 0xff) << 8 |
(h[offset + 3] & 0xff))
return str(binary % (10 ** digits)).zfill(digits)
print(totp('JBSWY3DPEHPK3PXP', 1700000000)) # -> 324550
Both implementations print 324550 for our fixed time, and both reproduce the official RFC 6238 test vectors (for example, the SHA-1 vector at T = 59 yields 94287082). If you swap SHA-1 for SHA-256 or SHA-512, or change the digit count, the verifier on the other side has to match the same choices exactly or the codes will never agree.
Verifying a TOTP Code Server-Side
Generating a code is half the system. The other half is the server deciding whether to accept the 6 digits a user just typed, and that step carries the security-sensitive trade-offs.
The server does not store codes. It stores the secret, and at login it recomputes the expected code from that secret and the current time, then compares. The catch is clock drift: the user’s device and the server rarely agree to the exact second, so a strict equality check would reject codes near a window boundary. The fix is a small validation window. Accept the current step and one step on either side, which means checking the codes for counters T−1, T, and T+1. A wider window tolerates more drift but enlarges the guessing surface, so window 1 (a ±30-second tolerance) is the usual balance. You can watch the same ±1-step tolerance at work on the tool’s Verify tab.
import { createHmac, timingSafeEqual } from 'crypto';
function verifyTotp(secret, code, { window = 1, period = 30, digits = 6 } = {}) {
const counter = Math.floor(Date.now() / 1000 / period);
const submitted = Buffer.from(code);
// Check the current step and ±window steps for clock drift.
for (let i = -window; i <= window; i++) {
const expected = Buffer.from(totpAt(secret, counter + i, digits));
// Constant-time compare so timing can't leak a partial match.
if (expected.length === submitted.length &&
timingSafeEqual(expected, submitted)) {
return counter + i; // matched step — store it to block replay
}
}
return false;
}
Two more details turn this from “works” into “safe.” First, replay prevention: store the last counter you accepted for each user and reject any code from a step at or below it, so a code sniffed once cannot be reused inside the same window. That is why verifyTotp returns the matching step rather than a bare true. Second, rate limiting: a 6-digit code is one of a million values, and a ±1 window makes three of them valid at any moment, so without throttling an attacker can brute-force the space. Lock the account or add backoff after a handful of failures. The secret itself is a long-lived key, so encrypt it at rest, keep it out of source control, and treat it like a password. Generate strong recovery codes alongside it for the day a device goes missing.
What TOTP Protects Against, and What It Doesn’t
TOTP is a real upgrade over passwords alone, but it is not magic, and marketing pages tend to gloss over the gaps. Here is the split.
| TOTP stops | TOTP does NOT stop |
|---|---|
| Leaked or reused passwords | Real-time phishing / adversary-in-the-middle |
| Credential stuffing | Malware that reads the secret off a device |
| Remote password brute force | Weak account-recovery flows that skip 2FA |
| A DB breach exposing only password hashes | (these need other defenses) |
The wins are substantial. Because login now requires a code only the secret can produce, a leaked password is no longer enough, which kills credential stuffing and remote brute force outright. If your database leaks but the TOTP secrets are encrypted at rest, the attacker still can’t mint codes.
The gaps are just as real. A real-time phishing proxy (an adversary-in-the-middle page) can show the user a perfect replica, capture the live code, and replay it to the real site within the same window. TOTP cannot tell that the code was typed into the wrong place. Malware on the device that exfiltrates the secret defeats it entirely, and a sloppy “forgot my 2FA” recovery flow can sidestep it altogether. One conflation worth clearing up: SIM-swap attacks defeat SMS one-time codes, not TOTP. TOTP has no phone-number channel, so there is nothing for an attacker to redirect.
So where does that leave you. Passkeys and FIDO2/WebAuthn are origin-bound, which makes them phishing-resistant by construction: the credential refuses to authenticate to the wrong domain. Treat TOTP as a strong, widely available step up from passwords, not the last word. It sits comfortably alongside the rest of your auth stack: see JWT security best practices for the session-token layer that rides on top of a verified login, and password hashing (bcrypt vs Argon2) for the password-at-rest layer that 2FA complements.
Common Pitfalls When Implementing TOTP
Most TOTP bugs are not in the algorithm, which the RFC pins down, but in the wiring around it. These are the ones that bite implementers.
- Server clock drift. If the server isn’t running NTP, its idea of “now” slides away from the user’s device and codes stop matching for everyone. Enable network time sync on every host.
- Plaintext or committed secrets. A secret in a config file checked into git is a permanent backdoor. Store it encrypted in a secrets manager, never in source control.
- No replay protection. If you accept a code without recording the step it matched, the same code works again inside its window. Persist the last-used step per user and reject reuse.
- A window that’s too wide or too narrow. Too wide multiplies the guessable codes and weakens security; too narrow rejects legitimate users on minor drift. Window 1 is the usual balance.
- Parameter mismatch. If enrollment encodes SHA-256 and 8 digits in the
otpauth://URI but the verifier assumes SHA-1 and 6 digits, no code will ever validate. Read the algorithm, digits, and period from the URI and use them on both sides. - No backup or recovery codes. When a phone is lost, the only way back in is a recovery path. Issue recovery codes at setup, and make them as strong as the account warrants. The same logic behind password entropy applies to recovery secrets too.
FAQ
Is TOTP phishing-proof?
No. TOTP stops leaked passwords and remote brute force, but a real-time phishing proxy can show a fake login, capture the live code, and relay it to the real site within the same 30-second window. Passkeys and FIDO2 are the phishing-resistant upgrade because they bind the credential to the site’s origin.
Is TOTP safer than SMS 2FA?
Yes. SMS codes travel over the cellular network and can be intercepted via SIM-swap or SS7 attacks, and they depend on your carrier’s security. TOTP has no phone-number channel and never transmits the code at all, so there is nothing to intercept in transit. The secret is exchanged once, at setup.
What happens if I lose my phone or authenticator app?
You need a backup arranged in advance. The options are recovery codes saved when you set up 2FA, a second device enrolled with the same secret, or the original Base32 secret stored somewhere secure. Without one of these, losing the device means you are locked out of the account.
How does a server verify a TOTP code?
It recomputes the expected code from the shared secret and the current time, then checks the submitted code against the current time step and one step on either side to allow for clock drift. It also records which step matched so the same code cannot be replayed, and rate-limits attempts to block guessing.
Why do TOTP codes refresh every 30 seconds?
Thirty seconds is the RFC 6238 default period: long enough to read and type the code comfortably, short enough that a code captured by an attacker expires almost immediately. Some systems use a 60-second period, which the otpauth:// URI records so the verifier matches it.
Can two devices share one TOTP secret?
Yes. Any device holding the same Base32 secret with a synced clock generates identical codes, because the algorithm is deterministic. That is exactly how multi-device authenticator backups work, and it is also why the secret must stay private: anyone who copies it can produce every future code.
Is TOTP the same as Google Authenticator?
No. TOTP is the open algorithm defined in RFC 6238. Google Authenticator, Authy, and 1Password are apps that implement it. Because the standard is shared, any compliant app works with any service that uses TOTP, with no lock-in to a particular vendor.
Conclusion
The core ideas are short enough to hold in your head:
- TOTP turns a shared secret plus the current time into a code via HMAC and truncation.
- Both sides compute the code independently; it is never sent over the network.
- Verify with a ±1-step window plus replay protection and rate limiting.
- It stops password attacks but not real-time phishing; passkeys close that gap.
- Keep server clocks synced with NTP and the secret encrypted and private.
To watch the algorithm produce real numbers and test your own verification window, open the TOTP / 2FA generator. It computes and verifies codes entirely in your browser, with the secret never leaving your device.