Among foundational cryptographic protocols in modern web security, RFC 6238 (TOTP: Time-Based One-Time Password Algorithm) stands out for its mathematical simplicity. It requires no interactive network handshakes, no centralized public key infrastructure (PKI), and no cloud round-trips. With only a shared Base32 secret key and the constant forward march of physical Unix time, a client and server compute identical, ephemeral 6-digit authentication tokens every 30 seconds with 100% mathematical certainty.
In this comprehensive guide, we bypass black-box third-party libraries (like pyotp or otplib) to build the complete RFC 6238 cryptographic pipeline from scratch in Python, JavaScript, and Go, verifying every implementation against official IETF test vectors.
01. The Elegance of Time-Driven Authentication
To understand TOTP, we must first trace its predecessor: RFC 4226 HOTP (HMAC-Based One-Time Password).
In HOTP, the client and server share an event counter $C$. Every time the user generates a token, the counter increments ($C = C + 1$), and the token is derived from $\text{HMAC-SHA-1}(K, C)$. While mathematically sound, HOTP suffered an operational weakness: counter desynchronization. If a user inadvertently pressed the button on their hardware dongle five times in their pocket, the client counter reached $C=6$ while the authentication server remained at $C=1$, resulting in persistent login rejections.
Published in May 2011 by N. M'Raihi, S. Machani, M. Pei, and J. Rydell, RFC 6238 solved the counter desync problem by replacing the incrementing integer with standardized Unix epoch time. Because physical time advances uniformly across the globe, both client and server advance their internal counters simultaneously without sending a single network packet.
02. The 5 Cryptographic Math Steps of TOTP
Transforming a 16-character Base32 seed (e.g., JBSWY3DPEHPK3PXP) into a 6-digit rolling code (e.g., 284915) involves five precise cryptographic phases:
Exchange enrollment keys are encoded in RFC 4648 Base32 using the alphabet A-Z and 2-7 (omitting ambiguous characters like 0, 1, 8, 9). The algorithm first normalizes this string, stripping whitespace and applying padding (=) so its length is a multiple of 8, before decoding it into raw binary key bytes $K$.
The current Unix timestamp (seconds elapsed since Jan 1, 1970 UTC) is mapped to integer counter $T$:
With standard parameters $T_0 = 0$ and step duration $X = 30$ seconds. This integer $T$ is then encoded as an 8-byte big-endian 64-bit unsigned integer.
Using raw key bytes $K$ as the secret key, we compute the HMAC-SHA1 digest over the 8-byte big-endian time buffer, yielding a 20-byte (160-bit) message authentication hash $HS$:
To convert 20 raw bytes into an integer, RFC 4226 specifies Dynamic Truncation:
- Extract Offset: Inspect the last byte $HS[19]$ and mask with
0x0Fto extract its lower 4 bits, giving an offset value between 0 and 15:Offset = HS[19] & 0x0F; - Slice 4 Bytes: Extract 4 consecutive bytes starting at Offset: $P = HS[Offset \dots Offset+3]$;
- Mask Sign Bit: Mask the most significant bit (MSB) with
0x7Fto prevent signed integer overflow ambiguity:
BinaryCode = ((P[0] & 0x7F) << 24) | ((P[1] & 0xFF) << 16) | ((P[2] & 0xFF) << 8) | (P[3] & 0xFF)
Finally, reduce BinaryCode modulo $10^6$ (1,000,000) for standard 6-digit tokens. Left-pad the result with zeroes to ensure a consistent 6-character output string:
03. Runnable Code 1: Python 3 Standard Library
This complete implementation uses Python's built-in base64, struct, hmac, and hashlib modules with zero pip packages required:
import base64 import hashlib import hmac import struct import timedef generate_totp(secret_base32: str, timestamp: int = None, step: int = 30, digits: int = 6) -> str: """ Generate an RFC 6238 compliant TOTP token. :param secret_base32: Shared secret key in Base32 format (e.g., ‘JBSWY3DPEHPK3PXP’) :param timestamp: Unix epoch timestamp in seconds. Defaults to time.time() :param step: Time-step window in seconds (default: 30) :param digits: Number of output digits (default: 6) :return: Left-padded OTP string (e.g., ‘045812’) """ if timestamp is None: timestamp = int(time.time())
1. Normalize Base32 string and append ’=’ padding characters
normalized_secret = secret_base32.replace(” ”, "").upper() padding_needed = (8 - len(normalized_secret) % 8) % 8 normalized_secret += ”=” * padding_needed key_bytes = base64.b32decode(normalized_secret)
2. Compute 64-bit big-endian time-step counter T
time_counter = timestamp // step time_bytes = struct.pack(“>Q”, time_counter) # 8 bytes big-endian unsigned long long
3. Calculate HMAC-SHA1 digest (20 bytes)
hmac_digest = hmac.new(key_bytes, time_bytes, hashlib.sha1).digest()
4. Perform Dynamic Truncation
offset = hmac_digest[-1] & 0x0F # Lower 4 bits of the 20th byte
Unpack 4 bytes at offset and clear MSB sign bit
binary_code = struct.unpack(“>I”, hmac_digest[offset : offset + 4])[0] & 0x7FFFFFFF
5. Modulo arithmetic and zero-fill formatting
otp_code = binary_code % (10 ** digits) return str(otp_code).zfill(digits)
if name == “main”: test_secret = “JBSWY3DPEHPK3PXP” print(f”Generated TOTP Code: {generate_totp(test_secret)}”)
04. Runnable Code 2: JavaScript & Node.js Crypto
Pure JavaScript implementation for backend Node.js runtimes using native crypto.createHmac:
const crypto = require('crypto');// RFC 4648 Base32 Decoder in Pure JavaScript function base32Decode(base32Str) { const alphabet = ‘ABCDEFGHIJKLMNOPQRSTUVWXYZ234567’; const cleaned = base32Str.replace(/=+$/, ”).toUpperCase().replace(/\s+/g, ”); let bits = 0, value = 0, index = 0; const output = new Uint8Array(Math.floor((cleaned.length * 5) / 8));
for (let i = 0; i < cleaned.length; i++) { const val = alphabet.indexOf(cleaned[i]); if (val === -1) continue; value = (value << 5) | val; bits += 5; if (bits >= 8) { output[index++] = (value >>> (bits - 8)) & 255; bits -= 8; } } return Buffer.from(output.buffer, 0, index); }
function generateTOTP(secretBase32, timestamp = Math.floor(Date.now() / 1000), step = 30, digits = 6) { const key = base32Decode(secretBase32); const counter = Math.floor(timestamp / step);
// Allocate 8-byte buffer in Big-Endian format const buffer = Buffer.alloc(8); buffer.writeBigUInt64BE(BigInt(counter), 0);
// Compute HMAC-SHA1 const hmac = crypto.createHmac(‘sha1’, key).update(buffer).digest();
// Dynamic Truncation const offset = hmac[hmac.length - 1] & 0x0f; const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
const otp = binary % (10 ** digits); return otp.toString().padStart(digits, ‘0’); }
console.log(“Calculated TOTP:”, generateTOTP(“JBSWY3DPEHPK3PXP”));
Client-Side Browser Execution via W3C Web Crypto API:
For modern web applications seeking 100% offline security (the exact architecture powering 2FA-Tool), the W3C Web Crypto API provides asynchronous, hardware-accelerated cryptographic primitives directly in the browser sandbox without third-party npm bundles:
async function generateBrowserTOTP(secretBase32, timestamp = Math.floor(Date.now() / 1000)) { const keyBytes = base32Decode(secretBase32); const counter = Math.floor(timestamp / 30);// 1. Import raw secret key into SubtleCrypto const cryptoKey = await window.crypto.subtle.importKey( “raw”, keyBytes, { name: “HMAC”, hash: { name: “SHA-1” } }, false, [“sign”] );
// 2. Encode 8-byte Big-Endian counter buffer const buffer = new ArrayBuffer(8); const view = new DataView(buffer); view.setBigUint64(0, BigInt(counter), false); // false = Big-Endian
// 3. Perform cryptographic HMAC sign const signature = await window.crypto.subtle.sign(“HMAC”, cryptoKey, buffer); const hmacResult = new Uint8Array(signature);
// 4. Dynamic Truncation const offset = hmacResult[hmacResult.length - 1] & 0x0f; const binary = ((hmacResult[offset] & 0x7f) << 24) | ((hmacResult[offset + 1] & 0xff) << 16) | ((hmacResult[offset + 2] & 0xff) << 8) | (hmacResult[offset + 3] & 0xff);
const otp = binary % 1000000; return otp.toString().padStart(6, ‘0’); }
05. Runnable Code 3: Production Golang (Go)
High-throughput microservices benefit from Go's battle-tested standard library:
package mainimport ( “crypto/hmac” “crypto/sha1” “encoding/base32” “encoding/binary” “fmt” “math” “strings” “time” )
// GenerateTOTP computes standard RFC 6238 time-based tokens func GenerateTOTP(secretBase32 string, timestamp int64, step int64, digits int) (string, error) { // 1. Sanitize Base32 seed and decode cleaned := strings.ToUpper(strings.ReplaceAll(secretBase32, ” ”, "")) key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned) if err != nil { key, err = base32.StdEncoding.DecodeString(cleaned) if err != nil { return "", err } }
// 2. Encode 64-bit Big-Endian time-step counter counter := uint64(timestamp / step) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, counter)
// 3. Compute HMAC-SHA1 mac := hmac.New(sha1.New, key) mac.Write(buf) hash := mac.Sum(nil)
// 4. Dynamic Truncation offset := hash[len(hash)-1] & 0x0f binaryCode := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff
// 5. Modulo arithmetic mod := uint32(math.Pow10(digits)) otp := binaryCode % mod
format := fmt.Sprintf(“%%0%dd”, digits) return fmt.Sprintf(format, otp), nil }
func main() { code, err := GenerateTOTP(“JBSWY3DPEHPK3PXP”, time.Now().Unix(), 30, 6) if err != nil { panic(err) } fmt.Printf(“Golang Computed TOTP: %s\n”, code) }
06. RFC 6238 Appendix B Test Vectors Verification
To prove standard compliance, every cryptographic implementation must be verified against the official test vectors published in IETF RFC 6238 Appendix B.
The official test suite uses the ASCII secret "12345678901234567890" (20 bytes), which encodes in Base32 as:
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ (evaluated for 8-digit outputs with 30-second steps):
| Unix Epoch Timestamp | UTC Date & Time | RFC Official Expected Code | Implementation Result |
|---|---|---|---|
| 59 | 1970-01-01 00:00:59 | 94287082 | ✓ PASSED (Exact Match) |
| 1111111109 | 2005-03-18 01:58:29 | 07081804 | ✓ PASSED (Exact Match) |
| 1111111111 | 2005-03-18 01:58:31 | 14050471 | ✓ PASSED (Exact Match) |
| 1234567890 | 2009-02-13 23:31:30 | 89005924 | ✓ PASSED (Exact Match) |
| 2000000000 | 2033-05-18 03:33:20 | 69279037 | ✓ PASSED (Exact Match) |
07. Production Engineering: Skew, Replay & Rate Limits
When deploying TOTP verification in high-availability enterprise authentication systems, three security requirements must be enforced:
Due to minor mobile clock inaccuracies, servers should evaluate the window [T - 1, T, T + 1] (checking the preceding 30s, current 30s, and next 30s). Never widen the window beyond $\pm 1$ step, as each additional step expands the replay attack surface by 30 seconds.
A token must be invalidated immediately upon successful consumption. Store Last_Verified_Time_Step in Redis or an in-memory cache. If a client attempts to submit the same token within the same 30-second window, reject the request to neutralize man-in-the-middle replay attacks.
Because 6-digit codes span only 1,000,000 permutations, unthrottled endpoints can be brute-forced rapidly. Enforce progressive delays and account lockouts after 3 consecutive failures. In code, perform comparisons using constant-time equality functions (e.g., crypto/subtle.ConstantTimeCompare in Go or crypto.timingSafeEqual in Node.js) to thwart side-channel timing analysis.
08. Frequently Asked Questions (FAQ)
Q1: Since SHA-1 is cryptographically broken, why does TOTP still default to HMAC-SHA1? ▼
The theoretical collision attacks against raw SHA-1 do not break the HMAC construction (RFC 2104). In HMAC, the underlying hash function is nested within internal and external secret keys, which neutralizes collision-based attacks. To date, HMAC-SHA1 remains secure for 2FA. Furthermore, RFC 6238 fully supports HMAC-SHA256 and HMAC-SHA512 if higher assurance is required.
Q2: Do time zones or Daylight Saving Time (DST) affect TOTP synchronization? ▼
No. Unix epoch time is defined as the elapsed seconds since 1970-01-01 00:00:00 UTC. It is strictly agnostic to time zones, leap seconds, and daylight saving shifts. At any exact moment in time, a server in Tokyo and a smartphone in California have the exact same integer timestamp.
Q3: How does Steam Guard 5-letter authentication differ from RFC 6238? ▼
Steam Guard is a proprietary variant based on RFC 6238. It uses the exact same 30-second time-step and HMAC-SHA1 calculation, but in the final reduction phase, it maps the extracted integer through a custom 26-character alphanumeric character array (23456789BCDFGHJKMNPQRTVWXY) to produce a 5-character string.
Experience 100% Client-Side TOTP Code Generation Live
2FA-Tool implements this exact cryptographic pipeline directly inside your browser using the W3C Web Crypto API. Zero server transmissions, zero token storage, instant validation.