← Back to blog

The Architecture of Time-Based One-Time Passwords: Inside RFC 6238 and HMAC Generation

PetexSpace

Multi-factor authentication (MFA) forms the baseline defense against credential stuffing, phishing, and session hijacking across modern distributed systems. Among secondary authentication mechanisms, Time-Based One-Time Passwords (TOTP) represent one of the most widely deployed open standards. Specified by the Internet Engineering Task Force (IETF) in RFC 6238, TOTP governs the rotating six-digit and eight-digit numerical codes produced by authenticator applications worldwide.

Unlike proprietary push notifications or Short Message Service (SMS) verification codes, TOTP operates entirely decoupled from immediate network transport. The user agent and the authentication server calculate identical authentication tokens independently, synchronized solely by elapsed Unix time and a pre-shared cryptographic key. Understanding the cryptographic foundation of TOTP requires dissecting its underlying primitives: the transformation of continuous time into discrete steps, the internal structure of Hash-based Message Authentication Codes (HMAC), the dynamic truncation algorithm defined in RFC 4226, and the practical security trade-offs governing clock drift, lookup windows, and local browser secret parsing.

For practitioners validating token generation workflows directly, utilizing an in-browser TOTP code generator allows direct verification of secret key behavior with privacy-preserving web tools. When provisioning accounts, systems often serialize these keys into matrix barcodes, making it essential to extract TOTP secrets with our QR decoder to audit seed formats and URI query parameters.

1. Foundations of One-Time Authentication: From HOTP (RFC 4226) to TOTP (RFC 6238)

To understand TOTP, one must first examine its direct ancestor: the HMAC-Based One-Time Password algorithm (HOTP), published by the IETF in RFC 4226. HOTP was designed around an incremental event counter. In an HOTP transaction, both the client token generator and the validation server share a secret key KK and maintain an internal 64-bit counter variable CC.

HOTP(K,C)=Truncate(HMAC-SHA-1(K,C))HOTP(K, C) = Truncate(HMAC\text{-}SHA\text{-}1(K, C))

Every time an authentication code is requested by the client, the local counter CC increments by 1 (Cnext=C+1C_{next} = C + 1), and a new cryptographic digest is computed. When the user submits the resulting code, the verification server computes the expected output using its stored counter CserverC_{server}. If the values match, the server increments CserverC_{server} and grants access.

The Inherent Vulnerabilities of Event-Based Counters

While mathematically sound, HOTP introduces substantial operational friction into distributed systems:

  1. Counter Desynchronization: If an end user activates the hardware token or taps an authenticator interface without submitting the generated code, the client counter advances while the server counter remains stationary. RFC 4226 addresses this by requiring the server to implement a look-ahead window parameter (ss). The server must compute and compare values across [Cserver,Cserver+s][C_{server}, C_{server} + s]. If ss is too small, users who trigger tokens accidentally become locked out. If ss is too large, the brute-force attack surface expands linearly.
  2. State Storage Overhead: The verifying server must maintain mutable, synchronized counter state across distributed database replicas. High-concurrency authentication clusters require transactional row locking to prevent race conditions and token replay attempts.

The RFC 6238 Time-Step Transformation

RFC 6238 resolves the desynchronization problem by replacing the incrementing event counter CC with a monotonically increasing integer derived directly from physical time. The continuous physical time coordinate is discretized into discrete time steps using floor division.

The discrete counter TT is formally computed as:

T=⌊CurrentUnixTime−T0X⌋T = \left\lfloor \frac{\text{CurrentUnixTime} - T_0}{X} \right\rfloor

Where:

  • CurrentUnixTime\text{CurrentUnixTime} represents the current elapsed seconds since the Unix Epoch (1970-01-01T00:00:00Z UTC), ignoring leap seconds according to POSIX conventions.
  • T0T_0 represents the initial epoch time offset, standardized as 0.
  • XX defines the time step window duration in seconds, set by convention and specification to 30 seconds (though implementations occasionally support 60-second intervals).
   Physical Timeline (Continuous UTC Seconds)
   ----------------------------------------------------------------------->
   [ 1730548800 s ]             [ 1730548830 s ]             [ 1730548860 s ]
   |                            |                            |
   +--- Step Window T = 57684960 +--- Step Window T = 57684961 +--- Step T = 57684962
   |                            |                            |
   v                            v                            v
   [ HMAC Compute: C=57684960 ]  [ HMAC Compute: C=57684961 ]  [ HMAC Compute: C=... ]
   [ Dynamic Truncation       ]  [ Dynamic Truncation       ]  [ Dynamic Truncation  ]
   v                            v                            v
   Token: "847291"              Token: "392014"              Token: "105928"

Because time advances automatically on both client hardware and verification clusters, the client and server remain inherently synchronized without exchanging state. The counter TT serves as an 8-byte input parameter to the core HMAC function, bridging the gap between time-invariant cryptographic hashes and ephemeral authentication challenges.

2. Mathematical Formulation of HMAC-Based Token Generation

The core cryptographic engine of TOTP relies on the Hash-based Message Authentication Code construct defined in RFC 2104. HMAC provides a mechanism for verifying both data integrity and message authenticity through an underlying cryptographic hash function HH, evaluated in conjunction with a shared secret key KK.

RFC 6238 natively supports multiple hashing primitives:

  • HMAC-SHA-1: The ubiquitous default specification. Despite known collision vulnerabilities in SHA-1 for digital signatures, HMAC-SHA-1 remains cryptographically secure against pseudorandom function (PRF) attacks because collisions do not expose key recovery.
  • HMAC-SHA-256: Recommended for high-assurance enterprise security profiles seeking resistance against future cryptanalysis.
  • HMAC-SHA-512: Employs a 64-bit word architecture matching modern 64-bit microprocessor registers.

Byte-Level Counter Formatting

Before computing the message digest, the 64-bit integer TT must be converted into an exact 8-byte (64-bit) big-endian (network byte order) array. If T=57684960T = 57684960, its hexadecimal representation is 0x00000000037043E0. In memory, this must be structured as eight discrete octets:

Bytes(T)=[0x00,0x00,0x00,0x00,0x03,0x70,0x43,0xE0]\text{Bytes}(T) = [0x00, 0x00, 0x00, 0x00, 0x03, 0x70, 0x43, 0xE0]

Padding or endianness mistakes at this stage represent the most frequent failure point in custom TOTP implementations.

Internal HMAC Mechanics

The HMAC construction operates on fixed block sizes BB (B=64B = 64 bytes for SHA-1 and SHA-256; B=128B = 128 bytes for SHA-512) and output lengths LL. The shared secret KK is normalized: if KK is longer than BB, it is hashed to length LL; if shorter, it is zero-padded on the right up to BB bytes.

Two constant byte masks are defined:

  • The inner pad: ipad=0x36ipad = 0x36 repeated BB times.
  • The outer pad: opad=0x5Copad = 0x5C repeated BB times.

The cryptographic digest HSHS is derived via nested hashing:

HMAC(K,T)=H((K⊕opad)∥H((K⊕ipad)∥T))HMAC(K, T) = H\Big((K \oplus opad) \parallel H((K \oplus ipad) \parallel T)\Big)

Where ⊕\oplus denotes bitwise XOR, and ∥\parallel denotes byte concatenation.

                  +-----------------------------------+
                  | Secret Key K (Base32 Decoded)     |
                  +-----------------------------------+
                                    |
                    +---------------+---------------+
                    |                               |
                    v                               v
             ( K XOR ipad )                  ( K XOR opad )
                    |                               |
                    v                               |
            [ Concatenate with T ]                  |
                    |                               |
                    v                               |
             Hash Function H                        |
                    |                               |
                    v                               v
            [ Intermediate Hash ] ------> [ Concatenate with Outer ]
                                                    |
                                                    v
                                             Hash Function H
                                                    |
                                                    v
                                         HMAC Digest HS (20-64 Bytes)

The resulting digest HSHS has a length matching the chosen hash algorithm: exactly 20 bytes for SHA-1, 32 bytes for SHA-256, and 64 bytes for SHA-512.

3. The Dynamic Truncation Algorithm (DT)

A 20-byte raw cryptographic hash output is unsuitable for direct manual entry by human operators. The Dynamic Truncation algorithm, specified in RFC 4226 Section 5.3, systematically extracts a 31-bit unsigned integer from the digest in a manner that distributes randomness uniformly while preventing integer overflow issues.

Offset Extraction

Let HSHS represent the resulting HMAC array indexed from 0 to 19 (for SHA-1). The algorithm inspects the lowest four bits (the low-order nibble) of the final byte, HS[19]HS[19].

Offset=HS[19] & 0x0FOffset = HS[19] \ \& \ 0x0F

Because a 4-bit nibble spans values from 00 to 1515 (0x00x0 to 0xF0xF), OffsetOffset is guaranteed to satisfy:

0≤Offset≤150 \le Offset \le 15

This mathematical guarantee ensures that reading four consecutive bytes starting at OffsetOffset (indices Offset,Offset+1,Offset+2,Offset+3Offset, Offset+1, Offset+2, Offset+3) will never exceed index 19 (15+3=18≤1915 + 3 = 18 \le 19), completely preventing array out-of-bounds exceptions.

31-Bit Integer Construction

Using the extracted OffsetOffset, four contiguous bytes are assembled into a 32-bit unsigned integer using big-endian bitwise shifts. Crucially, the most significant bit (MSB) of the first byte is masked with 0x7F0x7F rather than 0xFF0xFF.

P=((HS[Offset] & 0x7F)≪24)∣((HS[Offset+1] & 0xFF)≪16)∣((HS[Offset+2] & 0xFF)≪8)∣(HS[Offset+3] & 0xFF)P = \Big((HS[Offset] \ \& \ 0x7F) \ll 24\Big) \mid \Big((HS[Offset + 1] \ \& \ 0xFF) \ll 16\Big) \mid \Big((HS[Offset + 2] \ \& \ 0xFF) \ll 8\Big) \mid (HS[Offset + 3] \ \& \ 0xFF)

Masking the MSB with 0x7F0x7F zeroes out the signed bit, ensuring that the integer PP is treated as a positive 31-bit unsigned integer across architectures. This eliminates ambiguous behavior in programming environments lacking native unsigned integer types (such as older JavaScript implementations or specific SQL engines).

Modulo Reduction

Finally, the target dd-digit user authentication token is derived by applying modulo arithmetic to PP:

Token=P(mod10d)Token = P \pmod{10^d}

For the standard 6-digit code (d=6d = 6):

Token=P(mod106)Token = P \pmod{10^6}

If the resulting integer is smaller than 10d−110^{d-1}, it is left-padded with zeroes to maintain exactly dd characters. For example, if Token=4921Token = 4921, it is formatted as "004921".

/**
 * Constant-time dynamic truncation and TOTP code derivation in TypeScript
 * compliant with RFC 4226 and RFC 6238.
 */
export function dynamicTruncate(hmacDigest: Uint8Array, digits: number = 6): string {
  if (hmacDigest.length < 20) {
    throw new Error("HMAC digest length must be at least 20 bytes.");
  }

  // Step 1: Determine the 4-bit offset from the final byte
  const offset = hmacDigest[hmacDigest.length - 1] & 0x0f;

  // Step 2: Extract a 31-bit big-endian unsigned integer
  const binaryCode =
    ((hmacDigest[offset] & 0x7f) << 24) |
    ((hmacDigest[offset + 1] & 0xff) << 16) |
    ((hmacDigest[offset + 2] & 0xff) << 8) |
    (hmacDigest[offset + 3] & 0xff);

  // Step 3: Modulo reduction
  const tokenModulo = 10 ** digits;
  const tokenNumber = binaryCode % tokenModulo;

  // Step 4: Pad with leading zeroes to enforce string length
  return tokenNumber.toString().padStart(digits, "0");
}

4. Clock Drift, Window Skew, and Transmission Latency Mitigation

In ideal environments, the client clock and the server clock align perfectly to the exact millisecond. In real-world operational deployments, physical devices experience hardware clock drift, Network Time Protocol (NTP) failures, and variable transmission latency over cellular links.

The Validation Matrix and Skew Tolerances

If an end user submits an authentication code at second 29 of a 30-second window, network transit time may deliver the payload to the server at second 31. Under strict zero-tolerance validation (W=0W = 0), the authentication attempt would fail, creating severe user friction.

To accommodate drift and latency, RFC 6238 recommends supporting an operational verification window. A verification window of size W=1W = 1 instructs the server to calculate expected tokens across three distinct intervals:

Tcurrent=⌊CurrentUnixTimeX⌋T_{current} = \left\lfloor \frac{\text{CurrentUnixTime}}{X} \right\rfloor Tvalid={Tcurrent−1,  Tcurrent,  Tcurrent+1}\mathcal{T}_{valid} = \{ T_{current} - 1, \; T_{current}, \; T_{current} + 1 \}
                Client Submits Token
                        |
                        v
   Time Step T - 1      Time Step T          Time Step T + 1
   [ 00s ---- 30s ]     [ 30s ---- 60s ]     [ 60s ---- 90s ]
   +------------------+--------------------+------------------+
   |  Past Window     |  Current Window    |  Future Window   |
   |  (Acceptable)    |  (Nominal)         |  (Acceptable)    |
   +------------------+--------------------+------------------+
          \                   |                    /
           +------------------+-------------------+
                              |
                   Evaluated by Server Matrix

Expanding the window to W=1W = 1 grants an effective 90-second validation aperture. While this mitigates transient latency, expanding WW beyond 1 substantially inflates the probability of successful brute-force submission. For a 6-digit token, an attacker has a 10−610^{-6} chance of guessing a code in a single attempt; evaluating three windows increases the per-guess success rate to 3×10−63 \times 10^{-6}.

Replay Attack Protection via State Caching

Because an expanded validation window accepts codes that were mathematically valid 30 seconds ago, an eavesdropping adversary could intercept a submitted token and replay it before the window expires.

RFC 6238 mandates that authentication servers maintain a replay cache. When a token matches step Ti∈TvalidT_i \in \mathcal{T}_{valid}, the server must record the tuple (UserID,Ti)(UserID, T_i) in a transactional, high-speed cache (such as an in-memory key-value store) with a time-to-live (TTL) matching (2W+1)×X(2W + 1) \times X seconds.

If a subsequent request presents a token matching the same step TiT_i, the server rejects the request immediately:

Receive (Token, UserID)
   |
   +--> For each T in { T_current - W ... T_current + W }:
          ExpectedToken = CalculateTOTP(UserSecret, T)
          If ConstantTimeEquals(Token, ExpectedToken):
             If CacheExists(Key: UserID + ":" + T):
                 Return REJECT_REPLAYED_TOKEN
             Else:
                 WriteCache(Key: UserID + ":" + T, TTL: (2W + 1) * X)
                 Return ACCEPT_AUTHENTICATION
   |
   +--> Return REJECT_INVALID_TOKEN

Rate Limiting and Brute-Force Economics

Because the entire token domain consists of only 1,000,0001,000,000 values for 6-digit codes, applications must enforce strict threshold rate limiting. Verifying servers should enforce exponential backoff or temporary account lockouts after three to five consecutive failed attempts, preventing automated enumeration attacks within the 30-second window.

5. Secure In-Browser TOTP Execution Without Remote Exposure

Traditional developer utilities often implement cryptographic debugging tools by routing user input across backend APIs. In the context of two-factor authentication, submitting seed keys to a remote server compromises the entire multi-factor guarantee: anyone possessing the Base32 seed can generate valid tokens indefinitely.

PetexSpace addresses this challenge through complete in-browser execution. The entire cryptographic sequence (Base32 parsing, HMAC calculation, and dynamic truncation) is performed directly within the user agent's execution thread via the native Web Cryptography API (window.crypto.subtle), completely eliminating remote network transmissions.

                    IN-BROWSER EXECUTION ENVIRONMENT
   +----------------------------------------------------------------+
   |                                                                |
   |  +------------------------+      +--------------------------+  |
   |  | Base32 Encoded Seed    | ---> | Zero-Allocation Decoder  |  |
   |  | "JBSWY3DPEHPK3PXP"     |      | Uint8Array Key Buffer    |  |
   |  +------------------------+      +--------------------------+  |
   |                                               |                |
   |                                               v                |
   |  +------------------------+      +--------------------------+  |
   |  | DOM HighRes Time       | ---> | crypto.subtle.importKey  |  |
   |  | Floor Division (30s)   |      | "raw", HMAC-SHA-1        |  |
   |  +------------------------+      +--------------------------+  |
   |               |                               |                |
   |               v                               v                |
   |  +------------------------+      +--------------------------+  |
   |  | 8-Byte Big-Endian      | ---> | crypto.subtle.sign       |  |
   |  | ArrayBuffer Counter    |      | In-Memory Signing        |  |
   |  +------------------------+      +--------------------------+  |
   |                                               |                |
   |                                               v                |
   |                                  +--------------------------+  |
   |                                  | Dynamic Truncation       |  |
   |                                  | 6-Digit Output Token     |  |
   |                                  +--------------------------+  |
   |                                                                |
   |  (No Network Sockets, No Analytics, No Persistent Seed Writes)  |
   +----------------------------------------------------------------+

Web Crypto API Implementation

Modern browser environments provide constant-time cryptographic primitives implemented in native machine code via crypto.subtle. The listing below demonstrates standard-compliant, zero-transmission TOTP code generation:

/**
 * Generates an RFC 6238 compliant TOTP code locally inside the browser.
 * Utilizes the Web Cryptography API for high-assurance HMAC computation.
 */
export async function generateInBrowserTOTP(
  keyBytes: Uint8Array,
  timeStepSeconds: number = 30,
  digits: number = 6
): Promise<{ token: string; remainingSeconds: number }> {
  // Compute step counter from local high-resolution clock
  const epochSeconds = Math.floor(Date.now() / 1000);
  const timeStep = Math.floor(epochSeconds / timeStepSeconds);
  const remainingSeconds = timeStepSeconds - (epochSeconds % timeStepSeconds);

  // Allocate 8-byte big-endian buffer for the counter
  const counterBuffer = new ArrayBuffer(8);
  const counterView = new DataView(counterBuffer);
  // High 32 bits
  counterView.setUint32(0, Math.floor(timeStep / 0x100000000), false);
  // Low 32 bits
  counterView.setUint32(4, timeStep & 0xffffffff, false);

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

  // Compute HMAC digest in memory
  const signature = await window.crypto.subtle.sign("HMAC", cryptoKey, counterBuffer);
  const digestBytes = new Uint8Array(signature);

  // Apply RFC 4226 Dynamic Truncation
  const token = dynamicTruncate(digestBytes, digits);

  return { token, remainingSeconds };
}

Threat Modeling the Client-Side Context

While client-side execution eliminates transport layer vulnerabilities and server-side compromise risks, in-browser cryptographic architectures introduce distinct threat vectors that must be actively mitigated:

  1. Memory Scraping and Extension Injection: Untrusted browser extensions operating with broad <all_urls> permissions can inspect DOM contents or hook global constructors. PetexSpace mitigates this by avoiding persistent DOM reflection of raw secret keys, retaining decoded key buffers solely within ephemeral function scopes.
  2. Ephemeral Memory Management: Garbage-collected runtimes like V8 do not guarantee immediate memory zeroization. Where possible, developers must overwrite typed array buffers with zero fill patterns (keyBytes.fill(0)) immediately after importing the cryptographic key into crypto.subtle.
  3. Cross-Site Scripting (XSS): If an application executing cryptographic logic is compromised via XSS, attackers can execute arbitrary code within the user's execution context. Maintaining a strict Content Security Policy (CSP) that disallows unsafe-eval and restricts script execution solely to trusted hashes or nonces is essential.

By enforcing privacy-preserving web tools that execute entirely within the local sandbox, developers and security engineers can verify cryptographic implementations with absolute certainty that private seed credentials remain isolated within their own devices.