Implementing Time-Based One-Time Passwords (TOTP, RFC 6238) seems straightforward: generate a 160-bit shared secret, render an otpauth:// QR code, and verify a 6-digit code against an HMAC-SHA1 digest.
Yet, authentication services regularly encounter mysterious verification failures. A user enters the exact code shown on their screen, but the server rejects it. Here are 5 subtle edge cases in TOTP implementations and how to resolve them.
1. Base32 Padding and Whitespace Traps
TOTP secrets are encoded in Base32 (RFC 4648). However, standard Base32 implementations differ significantly on padding (= characters) and formatting:
-
Trailing Padding: Standard RFC 4648 requires 8-character blocks padded with
=. However, many authenticator apps omit padding in generated URIs. If your backend decoder expects strict padding, unpadded secrets throw an uncaught exception. -
Character Normalization: Users frequently copy-paste secrets containing spaces or dashes (e.g.,
JBSW Y3DP EHPK 3PXP).
function sanitizeBase32(secret: string): string {
// Strip whitespace, hyphens, and convert to uppercase
return secret.replace(/[\s-]/g, "").toUpperCase();
}
When decoding, always strip whitespace and make padding characters optional.
2. The 30-Second Window Boundary and Clock Drift
A standard TOTP time-step is 30 seconds ($T_0 = 0$, $X = 30$). The counter is calculated as:
$$C = \lfloor \frac{T - T_0}{X} \rfloor$$
If you only test against the current counter $C$, you will encounter two failure modes:
- Network Latency: A user submits the code at second 29; by the time the request hits your API, the server clock has rolled over to the next 30-second window.
- Client Clock Skew: Mobile device clocks can drift by 5 to 15 seconds if NTP sync is delayed.
// Check current step and adjacent steps (t - 1, t, t + 1)
function verifyToken(secret: Uint8Array, token: string, window = 1): boolean {
const currentStep = Math.floor(Date.now() / 1000 / 30);
for (let errorWindow = -window; errorWindow <= window; errorWindow++) {
const step = currentStep + errorWindow;
if (computeTOTPAtStep(secret, step) === token) {
// Prevent replay attacks: record step as used for this user
return markStepUsed(user.id, step);
}
}
return false;
}
Note: If you allow a window of $\pm 1$ step, always record the last successfully consumed time-step in your database or cache to prevent replay attacks within that 90-second validity envelope.
3. Google Authenticator and Algorithm Lock-In
RFC 6238 explicitly permits HMAC-SHA256 and HMAC-SHA512. The otpauth:// URI standard also supports the algorithm query parameter:
otpauth://totp/Acme:alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=6&period=30
However, Google Authenticator completely ignores the algorithm parameter and defaults strictly to SHA-1. If your backend generates an HMAC-SHA256 secret and token, users running Google Authenticator will generate mismatched codes, while users on 1Password or Aegis may succeed.
Unless you control the client app entirely, stick to HMAC-SHA1 with a 20-byte (160-bit) secret for universal 2FA compatibility.
If you are debugging client or server-side token generation, testing raw Base32 secrets against standard counters in an isolated tester like Nutilz TOTP Generator helps verify whether your HMAC offsets and truncation match RFC 6238 specifications.
4. 64-Bit Big-Endian Counter Serialization
The TOTP counter $C$ must be serialized as an 8-byte (64-bit) unsigned big-endian integer before being passed to HMAC:
function counterToBuffer(counter: number): ArrayBuffer {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
// High 32 bits (handling integers above 2^32)
view.setUint32(0, Math.floor(counter / 0x100000000), false);
// Low 32 bits
view.setUint32(4, counter >>> 0, false);
return buf;
}
In JavaScript and Python, treating the counter as a 32-bit integer or converting it as an ASCII string ("12345") instead of binary big-endian bytes is the #1 reason custom HMAC implementations fail.
5. Dynamic Truncation Offset Extraction
Once the HMAC digest (20 bytes for SHA-1) is generated, RFC 4226 dynamic truncation extracts a 4-byte code:
// 1. Take the low 4 bits of the last byte as the offset (0 to 15)
const offset = hmac[hmac.length - 1] & 0x0f;
// 2. Read 4 bytes starting at offset, masking the most significant bit (MSB)
const binary =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
// 3. Modulo 10^digits and zero-pad
const code = (binary % 1000000).toString().padStart(6, "0");
Notice the mask & 0x7f on hmac[offset]. This prevents signed integer overflow across platforms.
Summary Checklist for Production 2FA
- [x] Strip whitespace and handle unpadded Base32 secrets gracefully.
- [x] Use a $\pm 1$ time-step verification window with replay attack prevention.
- [x] Stick to
HMAC-SHA1for consumer-facing 2FA to ensure full compatibility with all authenticator apps. - [x] Pack the 64-bit counter as an 8-byte big-endian buffer.
- [x] Apply the
0x7fmask during dynamic truncation to avoid signed integer bugs.
To quickly inspect otpauth:// URIs, simulate clock drift, or verify Base32 token output against standard time intervals, you can use the free Nutilz TOTP Generator & Debugger.
Top comments (0)