ENES
TOTPEngineering Guide

How TOTP Two-Factor Authentication Works Under the Hood: RFC 6238 Mathematics, Dynamic Truncation, Time Drift Windows, and In-Browser 2FA Architecture (2026)

AC
Alex ChenΒ·Lead Systems Architect
Published on 2026-10-06Β·13 min readΒ·Daily Toolbox Engineering

How TOTP Two-Factor Authentication Works Under the Hood: RFC 6238 Mathematics, Dynamic Truncation, Time Drift Windows, and In-Browser 2FA Architecture (2026)

Every day, hundreds of millions of engineers, sysadmins, and security-conscious internet users open an authenticator appβ€”such as Google Authenticator, 1Password, Bitwarden, or Authyβ€”copy a 6-digit numeric code, and paste it into a login prompt before the 30-second countdown circle expires.

Yet if you put your smartphone into Airplane Mode with zero cellular reception, zero Wi-Fi, and Bluetooth disabled, the authenticator app continues to churn out perfectly valid 6-digit tokens like clockwork. When you type that offline code into a server located on the other side of the planet, the server accepts it instantly.

How can two devices on completely separate networks, without transmitting a single packet of data between them, agree on the exact same 6-digit number that changes every 30 seconds?

The answer lies in RFC 6238 (TOTP: Time-Based One-Time Password Algorithm), an elegant cryptographic protocol published by the Internet Engineering Task Force (IETF) in 2011.

While most high-level articles dismiss TOTP as "just hashing a timestamp with a secret key", the underlying engineering involves fascinating bitwise mechanics: big-endian binary time packing, HMAC-SHA1 cryptographic digestion, dynamic 4-bit truncation offsets, and sliding drift window tolerance.

In this comprehensive architectural guide, we unpack the mathematical derivation of RFC 6238, examine why Base32 encoding was chosen over Base64, dissect the security risks of time drift and replay attacks, and implement a production-grade, zero-dependency TOTP generator and verifier using modern Web Crypto standards.


1. The Architectural Evolution: From HOTP (RFC 4226) to TOTP (RFC 6238)

To understand TOTP, you must first understand its direct ancestor: HOTP (HMAC-Based One-Time Password), standardized in RFC 4226 in 2005.

The Foundation: RFC 4226 (HOTP)

HOTP was designed for physical hardware tokens (like RSA SecurID key fobs or YubiKeys). It relies on a shared secret key ($K$) and an event-based monotonic counter ($C$):

$$\text{HOTP}(K, C) = \text{Truncate}(\text{HMAC-SHA1}(K, C))$$

Every time a user presses the physical button on their hardware key:

  1. The hardware increments internal counter $C \leftarrow C + 1$.
  2. The key calculates the HMAC-SHA1 of counter $C$ using pre-shared key $K$.
  3. The result is dynamically truncated into a 6-digit number.
  4. The authentication server also increments its copy of counter $C$ and verifies the code.
HOTP (Event-Based Synchronization):
[Client Token (Button Press)] ──► Counter: 104 ──► Code: 492810
[Server Database]             ──► Counter: 104 ──► Code: 492810  (MATCH! Counter increments to 105)

The Desynchronization Failure Mode:
If the user presses the button 10 times in their pocket without logging in:
[Client Token] ──► Counter: 114 ──► Code: 839102
[Server DB]    ──► Counter: 104 ──► Code: 492810  (DESYNC! Server rejects code)

The fundamental flaw of HOTP is counter desynchronization. If the user accidentally triggers the hardware token or submits failed logins, the client's counter and the server's counter drift out of sync. Servers had to implement complex "lookahead windows" (e.g., checking $C+1$ through $C+50$) to re-synchronize, expanding the attack surface.

The Breakthrough: RFC 6238 (TOTP)

In 2011, the IETF solved the counter desynchronization problem with RFC 6238: replace the event counter ($C$) with current Unix epoch time ($T$).

Because physical clocks on smartphones and cloud servers both track the passage of time according to the universal standard of Coordinated Universal Time (UTC), time acts as an infinite, globally distributed, self-synchronizing counter.

$$\text{TOTP}(K, \text{now}) = \text{HOTP}(K, T)$$

Where the time-step counter $T$ is computed as:

$$T = \left\lfloor \frac{\text{Current Unix Time in Seconds} - T_0}{X} \right\rfloor$$

By standard convention:

  • $T_0 = 0$ (the Unix epoch: midnight January 1, 1970 UTC).
  • $X = 30$ seconds (the standard time step window).

Every 30 seconds, $T$ increments by exactly $1$. Whether your phone is in Tokyo, London, or a submarine with no cell service, $\lfloor \text{time} / 30 \rfloor$ produces the exact same integer on both client and server!


2. Step-by-Step Mathematical Derivation: How a 6-Digit Code is Born

Let's trace the exact binary operations required to turn a timestamp and a shared secret into the 6-digit code displayed on your screen.

High-Level TOTP Pipeline:

[Current Time: 1775437200s]
           β”‚
           β–Ό  (Divide by 30s & floor)
[Time-Step Counter T = 59,181,240]
           β”‚
           β–Ό  (Pack into 8-byte Big-Endian buffer)
[0x00, 0x00, 0x00, 0x00, 0x03, 0x87, 0x07, 0x98]
           β”‚
           β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β—„ [Shared Secret K (Base32 Decoded)]
           β–Ό
[HMAC-SHA1(K, T)] ──► 20-byte cryptographic digest (160 bits)
           β”‚
           β–Ό  (Dynamic Truncation: 4-bit offset lookup)
[Extract 4-byte Slice & Mask Bit 31 to 0] ──► 31-bit Unsigned Integer
           β”‚
           β–Ό  (Modulo 1,000,000 & zero-pad to 6 digits)
[Final 6-Digit Token: "482091"]

Step 1: Compute and Pack the 64-Bit Time Counter ($T$)

Suppose current Unix epoch time is 1,775,437,200 seconds. Dividing by 30 gives: $$T = \lfloor 1,775,437,200 / 30 \rfloor = 59,181,240$$

In hexadecimal, $59,181,240$ is 0x03870798.

Per RFC 4226/6238, $T$ must be serialized as an 8-byte (64-bit) unsigned integer in Big-Endian (network byte order):

Byte Index:   [0]   [1]   [2]   [3]   [4]   [5]   [6]   [7]
Hex Values:  0x00  0x00  0x00  0x00  0x03  0x87  0x07  0x98

Step 2: Compute the HMAC Hash

Next, we take the binary decoded shared secret key ($K$) and compute the HMAC-SHA1 digest over the 8-byte counter buffer:

$$\text{Digest} = \text{HMAC-SHA1}(K, \text{CounterBuffer})$$

SHA-1 produces a digest of exactly 20 bytes (160 bits).

Suppose the resulting 20-byte hash in hexadecimal is:

Byte:   00  01  02  03  04  05  06  07  08  09  10  11  12  13  14  15  16  17  18  19
Hex:    1f  8b  4c  90  a2  13  d8  7e  9b  c4  55  2a  01  4f  89  d2  3b  7c  e5  5b

Step 3: Dynamic Truncation (DT)

A 20-byte cryptographic hash is far too long for a human to type. How do we extract a reliable 6-digit number from it?

If we always extracted the first 4 bytes, attackers analyzing output streams might spot structural bias. Instead, RFC 4226 introduces Dynamic Truncation: the hash dynamically tells us where to look.

1. Determine the Offset

Look at the last byte (byte 19) of the 20-byte hash: In our example, byte 19 is 0x5b.

We apply a 4-bit bitmask (& 0x0F) to isolate the lowest 4 bits: $$\text{Offset} = \text{Hash}[19] \ & \ \text{0x0F}$$ $$\text{0x5b} \ & \ \text{0x0F} = \text{0x0b} \ (\text{decimal } 11)$$

Because a 4-bit number can only range from 0 to 15 (0x0 to 0xF), and we need a 4-byte window from a 20-byte buffer: $$\text{Max Offset} + 4 = 15 + 4 = 19 \le 19$$ The 4-byte slice is guaranteed to never overflow the 20-byte buffer.

2. Extract the 4-Byte Window

Starting at index 11, extract 4 consecutive bytes:

  • Byte 11: 0x2a
  • Byte 12: 0x01
  • Byte 13: 0x4f
  • Byte 14: 0x89

The combined 32-bit big-endian chunk is 0x2a014f89.

3. Mask Bit 31 to Zero (The Signed Integer Safeguard)

Why does RFC 4226 mandate & 0x7FFFFFFF? In legacy C and Java runtimes, 32-bit signed integers represent negative numbers when the most significant bit (bit 31) is 1. Performing modulo operations on negative integers yields negative results (-482091), which breaks formatting.

By masking bit 31 with 0x7FFFFFFF: $$\text{BinaryCode} = (\text{Slice} \ & \ \text{0x7FFFFFFF})$$ $$\text{0x2a014f89} \ & \ \text{0x7FFFFFFF} = \text{0x2a014f89} \ (\text{decimal } 704,737,161)$$


Step 4: Modulo Reduction to Desired Digits

To convert the 31-bit integer into a $d$-digit code (typically $d = 6$):

$$\text{OTP} = \text{BinaryCode} \pmod{10^d}$$ $$\text{OTP} = 704,737,161 \pmod{1,000,000} = 737,161$$

If the resulting number has fewer than 6 digits (e.g., 4523), it is zero-padded on the left: "004523".

Result: The user sees "737161" on their screen!


3. The Shared Secret: Why Base32 and Not Base64?

When you enable 2FA on GitHub, Google, or AWS, you are presented with a QR code and a fallback text string like:

JBSWY3DPEHPK3PXP

Why is this secret key encoded in Base32 (RFC 4648) rather than standard Base64 or Hexadecimal?

The Ergonomics of the Base32 Alphabet

The Base32 alphabet uses only 32 uppercase characters: A–Z and digits 2–7.

Standard Base32 Alphabet (RFC 4648):
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 2 3 4 5 6 7
Problem in Base64 / Hex How Base32 Solves It
Ambiguous Glyphs Digit 0 (zero) and letter O look identical on mobile screens. Number 1, lowercase l, and uppercase I look identical in sans-serif fonts. Base32 completely excludes 0, 1, 8, and 9, preventing transcription errors.
Case Sensitivity Base64 uses both lowercase and uppercase (a vs A). Base32 is strictly case-insensitive, so users typing on mobile keyboards without autocorrect will never fail due to capitalization.
Punctuation Characters Base64 requires symbols like +, /, and =. These trigger awkward keyboard layout shifts on iOS and Android keyboards. Base32 is purely alphanumeric.

The Standard otpauth:// URI Scheme

Under the hood, every 2FA QR code encodes a standard URI defined by Google's Key Uri Format:

otpauth://totp/DailyToolbox:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=DailyToolbox&algorithm=SHA1&digits=6&period=30
  • type: totp (or hotp for event counters).
  • label: Issuer:AccountName (displayed in the authenticator app UI).
  • secret: The unpadded Base32 encoded secret key.
  • algorithm: SHA1 (default), SHA256, or SHA512.
  • digits: 6 (default) or 8.
  • period: Time-step duration in seconds (30 default).

4. Time Drift, Clock Skew, and the Replay Attack Threat Model

In a theoretical world, all computer clocks are synchronized perfectly via atomic clocks. In reality, client clocks and server clocks inevitably drift.

A smartphone left in a desk drawer for three weeks, a user traveling on an airplane across timezones, or an uncalibrated virtual machine clock can easily be 10 to 45 seconds off from UTC.

Handling Clock Skew: The Sliding Window

If servers only accepted the exact current time step $T$, a user whose phone clock was running just 3 seconds fast would see their codes rejected 100% of the time.

To provide robust user experience, RFC 6238 specifies a verification window:

$$\text{Valid Timesteps} = [T - W, \dots, T, \dots, T + W]$$

Where $W$ is the window tolerance parameter.

Window Tolerance W = 1 (Standard Production Setting):

[Time T - 1: Previous 30s] ──► Valid (covers slightly fast server / slow phone)
[Time T    : Current  30s] ──► Valid (nominal sync)
[Time T + 1: Next     30s] ──► Valid (covers slightly slow server / fast phone)

Total Effective Code Lifetime: 30s * 3 = 90 seconds

Setting $W = 1$ allows for up to $\pm 30$ seconds of clock drift while maintaining high security. Setting $W > 2$ is considered an insecure anti-pattern, as it expands the window of vulnerability to over 2.5 minutes.


The Silent Vulnerability: Replay Attacks Within the Active Window

Because a single TOTP code is valid for 30 seconds (and up to 90 seconds under a $W = 1$ drift window), what happens if an eavesdropper intercepts the 6-digit code via man-in-the-middle (MitM) or shoulder-surfing?

Can the attacker replay that same code 5 seconds later before the timer expires?

CRITICAL SECURITY RULE: A One-Time Password must strictly be used EXACTLY ONCE.

To prevent Token Replay Attacks, production authentication systems must track consumed tokens:

-- Production Database Schema Example
CREATE TABLE user_totp_state (
    user_id UUID PRIMARY KEY REFERENCES users(id),
    secret_key BYTEA NOT NULL,
    last_verified_step BIGINT NOT NULL DEFAULT 0,
    failed_attempts INT NOT NULL DEFAULT 0
);

Verification Algorithm with Replay Protection:

  1. Receive incoming code from user.
  2. Calculate time step $T = \lfloor \text{now} / 30 \rfloor$.
  3. Check steps $t \in [T-1, T, T+1]$.
  4. If code matches step $t$:
    • Check: Is $t \le \text{last_verified_step}$?
    • If YES: REJECT IMMEDIATELY! (Token has already been consumed).
    • If NO: Update last_verified_step $\leftarrow t$, clear failed attempts, and allow login!

5. Zero-Dependency TypeScript Implementation (Web Crypto API)

Here is a clean, production-grade implementation of RFC 6238 TOTP using the native browser and Node.js Web Crypto API. Zero external npm dependencies.

/**
 * RFC 4648 Base32 Decoder
 */
function base32ToBytes(base32: string): Uint8Array {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const clean = base32.toUpperCase().replace(/=+$/, "").replace(/\s/g, "");
  
  let bits = 0;
  let value = 0;
  const output: number[] = [];

  for (let i = 0; i < clean.length; i++) {
    const idx = alphabet.indexOf(clean[i]);
    if (idx === -1) {
      throw new Error(`Invalid Base32 character: ${clean[i]}`);
    }
    value = (value << 5) | idx;
    bits += 5;

    if (bits >= 8) {
      output.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }

  return new Uint8Array(output);
}

/**
 * Generate a 6-digit TOTP code per RFC 6238
 * @param secretBase32 The user's shared secret in Base32 format
 * @param timestampSeconds Unix epoch time in seconds (defaults to now)
 * @param step Window period in seconds (default 30)
 * @param digits Number of output digits (default 6)
 */
export async function generateTOTP(
  secretBase32: string,
  timestampSeconds: number = Math.floor(Date.now() / 1000),
  step: number = 30,
  digits: number = 6
): Promise<{ code: string; secondsRemaining: number }> {
  const counter = Math.floor(timestampSeconds / step);
  const secondsRemaining = step - (timestampSeconds % step);

  // 1. Pack 64-bit integer counter in Big-Endian
  const counterBuffer = new ArrayBuffer(8);
  const dataView = new DataView(counterBuffer);
  // High 32 bits
  dataView.setUint32(0, Math.floor(counter / 0x100000000), false);
  // Low 32 bits
  dataView.setUint32(4, counter & 0xffffffff, false);

  // 2. Decode Base32 secret key into raw bytes
  const keyBytes = base32ToBytes(secretBase32);

  // 3. Import key into Web Crypto API
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    keyBytes,
    { name: "HMAC", hash: { name: "SHA-1" } },
    false,
    ["sign"]
  );

  // 4. Compute HMAC-SHA1
  const hmacSignature = await crypto.subtle.sign("HMAC", cryptoKey, counterBuffer);
  const hashBytes = new Uint8Array(hmacSignature);

  // 5. Dynamic Truncation: extract lowest 4 bits of 20th byte
  const offset = hashBytes[19] & 0x0f;

  // 6. Extract 4-byte slice and mask bit 31
  const binaryCode =
    ((hashBytes[offset] & 0x7f) << 24) |
    ((hashBytes[offset + 1] & 0xff) << 16) |
    ((hashBytes[offset + 2] & 0xff) << 8) |
    (hashBytes[offset + 3] & 0xff);

  // 7. Modulo 10^digits
  const otpNumber = binaryCode % Math.pow(10, digits);
  const code = otpNumber.toString().padStart(digits, "0");

  return { code, secondsRemaining };
}

/**
 * Verify a user-submitted TOTP code with time-drift window support
 */
export async function verifyTOTP(
  secretBase32: string,
  userInputCode: string,
  windowTolerance: number = 1
): Promise<boolean> {
  const now = Math.floor(Date.now() / 1000);
  const step = 30;

  for (let w = -windowTolerance; w <= windowTolerance; w++) {
    const checkTime = now + w * step;
    const { code } = await generateTOTP(secretBase32, checkTime, step, userInputCode.length);
    if (code === userInputCode) {
      return true;
    }
  }

  return false;
}

6. Authentication Matrix: How TOTP Compares to Other 2FA Methods

Security Dimension SMS One-Time Passwords Email Verification Codes TOTP (RFC 6238 Authenticator) FIDO2 / WebAuthn (Passkeys & YubiKeys)
Phishing Resistance ❌ Vulnerable (MitM easily forwards SMS) ❌ Vulnerable (MitM forwards email code) ⚠️ Partial (Vulnerable to real-time reverse proxies like Evilginx) πŸ›‘οΈ 100% Cryptographic Resistance (Bound to origin domain)
SIM Swapping Risk πŸ”΄ Severe (Carrier social engineering bypasses SMS) 🟒 None 🟒 Zero (Key stored on local device) 🟒 Zero
Offline Reliability ❌ Fails without cell tower reception ❌ Fails without internet connection βœ… Works 100% offline in Airplane Mode βœ… Works 100% offline
Cost & Latency πŸ”΄ Expensive (Carrier SMS gateway fees: ~$0.05/msg) 🟑 Moderate (Email delivery delays: 5-60s) 🟒 Free & Instantaneous (0 latency) 🟑 Requires hardware keys or modern OS
Regulatory Standing (NIST SP 800-63B) ❌ Deprecated / Discouraged ⚠️ Restricted βœ… Fully Approved (AAL2 Standard) πŸ† Gold Standard (AAL3 Highest)

7. Developer Tooling Matrix

When building or debugging two-factor authentication workflows, leverage DailyToolbox's suite of secure, client-side developer utilities:

Workflow Need Recommended Tool Architectural Application
2FA Testing & Generation TOTP Authenticator Generator Generate live 6-digit codes and verify time-step countdowns client-side without cloud transmission.
HMAC Verification HMAC Generator Calculate raw HMAC-SHA1, HMAC-SHA256, and HMAC-SHA512 hashes to audit cryptographic signatures.
Binary & String Conversion Base64 Encoder / Decoder Convert between raw hex strings, Base32, and Base64 representations.
Epoch Time Inspection Unix Timestamp Converter Inspect epoch timestamps and verify exact 30-second time-step bucket allocations.
Emergency Recovery Codes Password Generator Generate cryptographically random 16-character backup recovery codes with high entropy.

8. Frequently Asked Questions (FAQ)

Q1: Why does Google Authenticator still use SHA-1 instead of SHA-256 or SHA-512?

While collision attacks (like SHAttered) exist against SHA-1 in digital signature verification, HMAC-SHA1 remains cryptographically secure against pre-image attacks. In HMAC, the secret key is prepended and hashed twice ($H(K \oplus \text{opad} \parallel H(K \oplus \text{ipad} \parallel M))$), rendering collision attacks completely ineffective. Furthermore, because RFC 6238 only extracts a 31-bit integer out of the hash, the extra bit depth of SHA-256 offers virtually zero practical security gain for 6-digit numeric outputs.

Q2: What happens if an attacker steals my QR code or Base32 secret?

If an attacker obtains your Base32 secret key, they can clone your authenticator onto their device and generate the exact same codes. The security of TOTP rests entirely on the confidentiality of the shared secret. This is why backup codes, recovery phrases, and authenticator export QR codes must be guarded with the same rigor as master passwords.

Q3: Can an attacker reverse-engineer the secret key by intercepting multiple 6-digit codes?

Mathematically impossible. Each 6-digit code represents a one-way dynamic truncation and modulo reduction of a 160-bit HMAC output. Because the HMAC function is a cryptographic pseudo-random function (PRF), knowing hundreds of past 6-digit outputs yields zero computational advantage in discovering the underlying 128-bit or 160-bit secret key.

Q4: Is TOTP vulnerable to modern adversary-in-the-middle (AitM) phishing attacks?

Yes. While TOTP eliminates SIM swapping and credential stuffing, it is not origin-bound. If an attacker tricks a user into entering their username, password, and TOTP code onto a fraudulent phishing proxy (e.g., using tools like Evilginx), the proxy server can forward the 6-digit code to the legitimate service within the active 30-second window and establish an authenticated session. To defeat AitM phishing, organizations are actively migrating toward FIDO2 / WebAuthn Passkeys, which cryptographically bind authentication tokens to the browser's origin domain via public key cryptography.

#TOTP#2FA#RFC 6238#HOTP#Cryptography#Cybersecurity#HMAC#Web Security#Authentication
AC
Written by Alex ChenLead Architect

Alex Chen is a distributed systems engineer and core maintainer at Daily Toolbox with over 10 years of experience in client-side web technologies, RFC standards compliance, and cryptographic protocols. He specializes in zero-knowledge client architectures and WebAssembly-accelerated algorithms.