How TOTP Two-Factor Authentication Works (RFC 6238)

How TOTP Two-Factor Authentication Works (RFC 6238)

You open Google Authenticator, see a six-digit number, type it into a login form, and you're in. Thirty seconds later that number is dead and a fresh one has taken its place. No internet, no SMS, no push notification — the app on your phone and the server on the other side of the world both arrive at the same six digits independently. That looks like magic. It isn't. It's an HMAC-SHA1 hash of the current time, truncated to six digits, governed by a four-page RFC. Once you see how the pieces fit, the whole thing becomes obvious — and a lot of the myths around 2FA fall apart.

The 30-Second Version

TOTP — Time-based One-Time Password — is defined in RFC 6238. It's a thin wrapper over HOTP (RFC 4226) with one swap: instead of an incrementing counter, you use the current Unix timestamp divided by 30.

Both sides — your authenticator app and the server — share a secret. They both know the time. They run the same function over the same inputs. They get the same six-digit code. The code is valid for one 30-second window, then it's gone.

TOTP = HOTP(secret, floor(unix_time / 30))
HOTP(K, C) = Truncate(HMAC-SHA1(K, C)) % 10^6

That's the whole algorithm. The "T" counter ticks once every 30 seconds: at Unix time 1778587200, it equals 1778587200 / 30 = 59286240, and stays there until :30. The smooth circular timer in your authenticator app is cosmetic — the actual counter is a discrete step function. Thirty seconds is the RFC recommendation and effectively universal; almost no service uses anything else, because shorter windows punish slow typists and longer ones widen the attack surface.

The Shared Secret

sequenceDiagram
  participant App as Authenticator app
  participant U as User
  participant S as Server
  Note over U,S: One-time enrollment
  U->>S: "Enable 2FA"
  S->>S: Generate random 20-byte secret
  S->>U: Display QR code (otpauth:// URI)
  U->>App: Scan QR code
  App->>App: Store secret in Keychain / Keystore
  Note over App,S: Both sides now hold the same secret;<br/>it never travels the network again
  Note over U,S: Every subsequent login
  App->>App: code = HMAC-SHA1(secret, time/30)
  S->>S: code' = HMAC-SHA1(secret, time/30)
  U->>S: Submit 6-digit code
  S->>S: Compare; accept if equal (within drift window)

When you scan that QR code during 2FA setup, you're not transferring a password. You're transferring a random byte string — usually 20 bytes (160 bits) — encoded in base32. Base32 is used instead of base64 because it's case-insensitive and lacks ambiguous characters like O/0 or 1/I/l, which matters when somebody types the secret manually as a fallback.

The QR code itself is just a URI in the otpauth:// scheme:

otpauth://totp/GitHub:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30

You can build one with our QR generator — paste an otpauth:// URI and any compatible app (Google Authenticator, Authy, 1Password, Bitwarden) will recognize it.

This secret never traverses the network again after enrollment. The server stores it (encrypted at rest, hopefully). Your authenticator app stores it (in the iOS keychain, Android Keystore, or its own encrypted vault). Both sides regenerate codes from this secret indefinitely — no token refresh, no expiry, no rotation, unless you delete the entry and re-enroll.

That gives TOTP its first big property: it's offline. Your authenticator app doesn't phone home. The server doesn't push a code to your phone — your phone derives the same code the server is about to derive, independently.

The HMAC

HMAC-SHA1 is a keyed hash. You give it a key and a message, you get back 20 bytes that depend on both. Without the key, you can't reproduce the output. With the key, you always get the same output for the same message.

In TOTP, the key is your shared secret and the message is the 8-byte big-endian encoding of the time counter:

function hotp(secret, counter) {
  const counterBuf = Buffer.alloc(8);
  counterBuf.writeBigUInt64BE(BigInt(counter));

  const hmac = crypto.createHmac('sha1', secret);
  hmac.update(counterBuf);
  return hmac.digest();  // 20 bytes
}

function totp(secret, time = Date.now() / 1000) {
  const counter = Math.floor(time / 30);
  const hash = hotp(secret, counter);
  return truncate(hash);
}

Yes, SHA1. The use of SHA1 here makes people nervous because SHA1 is broken for collision resistance — but TOTP doesn't depend on collision resistance. It depends on HMAC's pseudo-randomness, which is unbroken even with SHA1. RFC 6238 also defines SHA-256 and SHA-512 variants, but most apps and servers stick with SHA1 because it's universal. Our hashing algorithms guide goes deeper, and you can experiment with raw hash output via the hash generator.

Dynamic Truncation

20-byte HMAC-SHA1 output (40 hex chars) 0 1 10 11 12 13 19 Step 1 offset = HMAC[19] & 0x0f → e.g. 10 Step 2 read 4 bytes at HMAC[10..13] Step 3 mask top bit: bytes[0] &= 0x7f (always positive int32) Step 4 int32 % 1 000 000 → six-digit code Offset varies between counters → no two consecutive codes share bytes
Dynamic truncation: the low 4 bits of the last HMAC byte select which 4 bytes become the code. Attackers can't predict which window will be sampled.

The HMAC output is 20 bytes — way too long for a human to type. We need to compress it down to six digits. RFC 4226 defines a clever trick called dynamic truncation that picks 4 bytes from a non-fixed offset within the hash:

function truncate(hash) {
  // Last byte's low 4 bits are the offset (0-15)
  const offset = hash[hash.length - 1] & 0x0f;

  // Read 4 bytes starting at offset, mask the high bit
  const binary =
    ((hash[offset]     & 0x7f) << 24) |
    ((hash[offset + 1] & 0xff) << 16) |
    ((hash[offset + 2] & 0xff) << 8)  |
     (hash[offset + 3] & 0xff);

  return binary % 1000000;  // Keep last 6 digits
}

A couple of subtle details: the offset is itself derived from the hash, so an attacker can't predict which 4 bytes will become the code. The high bit gets masked off so the result is always positive — sidesteps signed-integer surprises across languages. And % 10^6 keeps six digits; the RFC also supports 7 or 8.

To make this concrete, pretend the HMAC output ends in the byte 0x4a. The low 4 bits give offset 10, so we read bytes 10–13. Suppose those bytes are 0x50 0xef 0x7f 0x19. After masking the top bit and reading as a 32-bit integer, that's 1357701401. Modulo a million is 701401 — your six-digit code. Run it again 30 seconds later with the next counter and the four bytes you read could be in a totally different region of the hash, with a totally different value. Codes look uncorrelated even though the secret never changed.

The output range is 000000999999 — exactly one million possibilities. With a 30-second window, an unthrottled attacker would need around half a million guesses for a 50% chance of hitting one valid code. That's why every TOTP implementation must rate-limit verification attempts (typically 5 wrong codes in a row triggers a lockout). Without that, six digits is too low to be a serious second factor.

Clock Drift and the Verification Window

TOTP verification window (current counter ± 1 step) T - 2 T - 1 ✓ T (now) accepted T + 1 ✓ T + 2 rejected rejected total accepted: ~90 s window (3 × 30 s)
Servers usually accept the current 30-second slot plus one slot before and after, giving roughly a 90-second tolerance for clock skew.

The algorithm assumes both sides agree on the time. They don't. Phones can be off by a few seconds from bad NTP sync; servers can drift too; a user might tap submit 28 seconds into a window and the code arrives just as the counter ticks. So servers don't check only the current counter — they check a window, usually current ± 1 step:

function verify(secret, code, time = Date.now() / 1000) {
  const counter = Math.floor(time / 30);
  for (const offset of [-1, 0, 1]) {
    const expected = totp(secret, (counter + offset) * 30);
    if (constantTimeEqual(expected, code)) return true;
  }
  return false;
}

That extends the effective window from 30 seconds to about 90. Some services go wider (± 2 or ± 3 steps); wider is more forgiving but increases the attack surface — each extra step is another valid code for an attacker to guess. A nice optimization: remember which offset succeeded last time for each user, and use it to detect persistent drift. Constant-time comparison matters here too — a naive code === expected leaks timing information. Use crypto.timingSafeEqual or equivalent.

What TOTP Protects Against (and What It Doesn't)

TOTP defends against: password reuse (different secret per site), credential stuffing (the right password isn't enough without a fresh code), and SIM-swap attacks (it doesn't use SMS).

TOTP does not defend against real-time phishing. Tools like Evilginx proxy the login flow: the user types their password and TOTP code into a fake site, the attacker forwards both to the real site, and gets a session cookie. The TOTP code is consumed legitimately. That's why WebAuthn / passkeys are now favored over TOTP for high-value accounts — passkeys bind credentials to the origin, so they refuse to authenticate to a fake domain.

sequenceDiagram
  participant U as User
  participant Phish as evil-site.com<br/>(reverse proxy)
  participant Real as real-site.com
  U->>Phish: GET evil-site.com (looks identical)
  Phish->>Real: GET real-site.com (forwards)
  Real-->>Phish: Login form
  Phish-->>U: Login form
  U->>Phish: username + password + 6-digit TOTP
  Phish->>Real: username + password + 6-digit TOTP
  Real-->>Phish: Set-Cookie: session=...
  Phish->>Phish: Steal session cookie
  Phish-->>U: "Welcome" page
  Note over Phish,Real: Attacker now has a logged-in session;<br/>TOTP was used legitimately and is now consumed

It also doesn't help if there's malware on your phone (the attacker has the secret) or if your service's database leaks plaintext secrets (all 2FA tokens compromised at once — encrypt at rest with a KMS key the DB can't access).

For most users protecting most accounts, TOTP is a massive upgrade over passwords alone. For admin accounts and anything where a successful phish would be catastrophic, passkeys beat TOTP. The OWASP MFA cheat sheet recommends layering: TOTP as the baseline, passkeys for the truly sensitive operations.

Implementation Pitfalls

A few mistakes that come up over and over when people roll their own TOTP:

  1. Plaintext secrets. Encrypt the secret column with a KMS key the database can't reach on its own. Otherwise SQL injection equals total 2FA bypass for everyone.
  2. No rate limiting. Brute-forcing a 6-digit code with no throttle takes minutes. Cap at 5–10 wrong attempts and lock or back off exponentially. Our API rate limiting post covers the patterns.
  3. No replay protection. A code valid for 90 seconds can be used twice. Track the last-used counter per user and reject anything ≤ that — stops a phisher who captured one code from reusing it.
  4. Weak secrets. Math.random() is not a secure RNG. Use crypto.randomBytes(20) (Node) or secrets.token_bytes(20) (Python).
  5. No recovery codes. Phones get lost. Issue 8–10 single-use backup codes at enrollment. Without them, a lost phone equals a locked account. Our password strength checker helps when users compose their own recovery phrases.

TL;DR

If you want to internalize the algorithm, build it — it's about 30 lines of JavaScript end to end. Our browser-based TOTP generator does this with no network calls; paste a base32 secret and watch six digits regenerate every 30 seconds. Compare its output to Google Authenticator with the same secret and they'll agree exactly, because they're implementing the same RFC.

  • TOTP is HMAC-SHA1 over a 30-second time counter, truncated to 6 digits. That's the whole algorithm.
  • The shared secret never moves after enrollment. Both sides derive codes independently.
  • Drift handling means a ± 1 step verification window. Wider is friendlier but riskier.
  • Always rate-limit verification, encrypt secrets at rest, and track last-used counters to prevent replay.
  • TOTP defeats credential stuffing and SIM-swap attacks but not real-time phishing — pair it with passkeys for high-value accounts.
  • Six lines of math, twenty bytes of secret, infinite codes for life.

FAQ

Is TOTP secure against phishing?

No. A real-time phishing kit like Evilginx proxies your login through a fake site — you type your password and the 6-digit code into the fake site, the attacker forwards both to the real site, and gets a session cookie. The TOTP code was used legitimately. Passkeys (WebAuthn) defeat this because they bind credentials to the origin and refuse to authenticate to a fake domain.

Why does TOTP still use SHA-1 in 2026?

Because TOTP doesn't depend on SHA-1's collision resistance — it depends on HMAC's pseudo-randomness, which is unbroken even with SHA-1. RFC 6238 supports SHA-256 and SHA-512 variants, but most authenticator apps default to SHA-1 for ecosystem compatibility. There's no actual security issue; it's a quirk of how cryptographic primitives are graded.

Can I lengthen the 30-second window to make it friendlier?

Technically yes (the RFC allows any period), but practically no — almost no service does, because most authenticator apps assume 30. If you change the period, expect users to complain that "their code is wrong" when really their app is using the standard 30-second period. Stick with 30s, accept ± 1 step drift on the server.

What happens when a user loses their phone?

Without recovery codes, they're locked out — there's no way to reconstruct the shared secret. That's why every TOTP implementation should issue 8-10 single-use backup codes at enrollment, stored somewhere the user can find them in an emergency (printed, in a password manager). Without recovery codes, a lost phone is a permanently locked account.

Should I use TOTP or passkeys in 2026?

Passkeys (WebAuthn) are stronger, especially against phishing, and the UX is finally good — Apple, Google, and Microsoft all support them in 2026. Use passkeys as the primary second factor and TOTP as a fallback for users without compatible devices. For sensitive operations (admin actions, financial transactions), require passkeys; let consumer accounts have TOTP.

Why is my TOTP code wrong even though I just generated it?

Almost always clock skew — your phone or server is off from real time by more than 30-60 seconds. The fix is to ensure both ends sync to NTP (most phones do this automatically; servers may need explicit chrony or systemd-timesyncd config). The server can also widen its verification window to ± 2 or ± 3 steps if drift is chronic.

Can the server compute the next code if it knows my secret?

Yes — and that's an important security property. The server stores your shared secret to verify codes, but if the database leaks, every user's 2FA is compromised. Always encrypt secrets at rest with a key the database itself can't reach (KMS, HSM, application-side AES with the key in environment config). SQL injection alone shouldn't reveal secrets.

Is SMS 2FA a substitute for TOTP?

No, and it's strictly worse. SMS is vulnerable to SIM-swap attacks, where an attacker convinces your carrier to port your number to their device. TOTP runs offline on your phone, derives codes from a local secret, and never touches the cellular network. Treat SMS 2FA as legacy and migrate users to TOTP or passkeys when possible.