TOTP 2FA Verification Errors: Diagnosis and Fixes
Quick answer
💡TOTP 2FA failures are almost always caused by time synchronization problems: the server and the authenticator app disagree on the current time, so their independently generated codes do not match. RFC 6238 recommends accepting codes from the current window plus or minus one 30-second window, providing a 90-second tolerance for clock drift. If codes are consistently rejected, check NTP synchronization on the server (timedatectl status), verify the TOTP secret is correctly base32-encoded, and ensure the shared secret was not truncated during enrollment.
Error symptoms
- ✕
Invalid one-time password — TOTP code rejected even though the app shows a currently active code - ✕
Two-factor authentication failed. Please try again after a moment — indicating clock drift between server and app - ✕
TOTP setup QR code scanned successfully but all generated codes are rejected at verification - ✕
Codes work for the first few months after setup then start failing intermittently - ✕
Recovery codes rejected during account recovery, suggesting they were stored incorrectly - ✕
Error: base32.decode: invalid character — server-side TOTP secret has encoding corruption
Common causes
- •Server clock drift exceeding the 90-second tolerance window, causing the server to generate a different TOTP code than the authenticator app
- •TOTP secret stored in the database without proper base32 encoding, corrupting the shared secret
- •Shared secret truncated during storage (for example, a VARCHAR(20) column holding a 32-character base32 secret)
- •TOTP implementation using a tolerance window of 0 (only current period) instead of the RFC 6238 recommended window of 1
- •Recovery codes hashed in the database but comparison not using constant-time equality, causing timing side-channel
- •User's device clock is wrong (common with airplane mode or dead battery scenarios) and authenticator app generates codes from wrong timestamp
When it happens
- •In virtualized environments (VMs, containers) where the guest clock drifts from the host clock without NTP correction
- •After a server migration where NTP was not configured on the new host
- •When a user switches to a new phone and manually sets time instead of using automatic time synchronization
- •After a developer tests TOTP with a hardcoded timestamp and accidentally deploys that code to production
- •When the base32 TOTP secret is displayed as a manual entry key and the user misreads O as 0 or I as 1
Examples and fixes
A strict TOTP implementation that only accepts codes from the exact current 30-second window will reject valid codes from users with slight clock drift. Add RFC 6238 recommended window tolerance.
TOTP verification with proper time window tolerance
❌ Wrong
const speakeasy = require('speakeasy');
function verifyTotp(secret, userToken) {
// window: 0 means only the exact current time step
// Rejects valid codes from users with any clock drift
return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: userToken,
window: 0 // too strict — will cause false rejections
});
}✅ Fixed
const speakeasy = require('speakeasy');
function verifyTotp(secret, userToken) {
// window: 1 accepts current period plus 1 period before/after
// Provides 90-second tolerance per RFC 6238 recommendation
const result = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: userToken,
window: 1 // ±1 time step = ±30s = 90s total tolerance
});
// Log successful verification time step for monitoring
// Never log the actual token value
return result;
}RFC 6238 specifies that verifiers should accept codes from the current time step plus and minus one step (total of 3 steps or 90 seconds) to account for clock drift between the user's device and the server. Setting window to 0 (only current step) means any clock deviation over a few seconds causes a false rejection. Most production TOTP implementations use window 1. Using window 2 or higher (150 seconds tolerance) is generally considered too permissive for security. The comment about not logging the token value is important — OTP codes should be treated with the same sensitivity as passwords.
TOTP secrets must be base32-encoded, stored at full length, and never logged. A common bug is storing the raw binary secret or a truncated base32 string.
Generating and storing TOTP secrets correctly
❌ Wrong
const crypto = require('crypto');
function setupTotp(userId) {
// BUG 1: Storing raw random bytes, not base32
const rawSecret = crypto.randomBytes(20);
// BUG 2: rawSecret.toString() gives invalid base32
db.query('UPDATE users SET totp_secret=? WHERE id=?',
[rawSecret.toString(), userId]);
// BUG 3: Logging the secret
console.log('TOTP secret for user', userId, ':', rawSecret);
return rawSecret;
}✅ Fixed
const speakeasy = require('speakeasy');
function setupTotp(userId) {
const secret = speakeasy.generateSecret({
length: 20, // 20 bytes = 160 bits, good entropy
name: 'YourApp', // Shows in authenticator app
issuer: 'YourCompany'
});
// Store ONLY the base32 representation
// secret.base32 is the QR code value and storage value
await db.query(
'UPDATE users SET totp_secret=?, totp_enabled=0 WHERE id=?',
[secret.base32, userId] // VARCHAR(256) minimum
);
// Return QR code URL for display — never log secret
return secret.otpauth_url;
}TOTP secrets are 20-byte (160-bit) random values that must be base32-encoded for storage and for display to users as manual entry keys. Raw binary stored as a string will contain non-printable characters and will not decode correctly. The speakeasy library's generateSecret function handles entropy generation, base32 encoding, and otpauth:// URL construction for the QR code in a single call. Storing the secret in a VARCHAR(20) column when base32(20 bytes) = 32 characters is a common truncation bug. Store at least VARCHAR(256) to accommodate longer secrets and future algorithm changes. Never log TOTP secrets — treat them with the same security controls as password hashes.
How TOTP Works and Why It Fails
TOTP (Time-Based One-Time Password) is defined in RFC 6238, which extends HOTP (RFC 4226) by replacing the counter with a time value. The algorithm is: TOTP = HOTP(K, T) where K is the shared secret (a base32-encoded random value) and T is the number of 30-second intervals elapsed since Unix epoch (floor(current_unix_timestamp / 30)). Both the authenticator app and the server perform this exact same computation independently. If they agree on K and T, they produce the same 6-digit code.
Time drift is the primary cause of TOTP failures. The authenticator app uses the device clock and the server uses the server clock. If these clocks differ by more than 30 seconds, they are computing codes for different time steps and will always produce mismatched codes. RFC 6238 recommends that verifiers accept codes from the current time step plus or minus one step (±30 seconds, 90 seconds total tolerance) to accommodate clock drift. An implementation that only accepts the exact current step (window = 0) will reject valid codes from users with 30+ second clock drift.
The shared secret must be identical on both sides. During TOTP setup, the server generates a random secret, encodes it as base32, and presents it to the user as a QR code containing an otpauth:// URL. The user scans this QR code, which stores the base32 secret in the authenticator app. If this secret is corrupted — through truncation in a VARCHAR column, character encoding issues, or copying errors in manual entry — the two sides have different secrets and will never produce matching codes.
Base32 encoding uses the alphabet A-Z and 2-7, with = as a padding character. Manual entry is error-prone because users can confuse O (letter) with 0 (zero), I (letter) with 1 (one), or misread 2 as Z. RFC 4648 specifies that base32 is case-insensitive. Most TOTP libraries normalize input to uppercase before verification, but some do not. If your verification code calls base32.decode() directly rather than through a TOTP library, ensure you normalize the input.
HOTP (counter-based OTP, RFC 4226) uses a monotonically increasing counter instead of time. HOTP codes are valid until used, which means a user who generates codes without submitting them will drift ahead of the server's counter. Both protocols use the same HMAC-SHA1 computation (RFC 6238 note: TOTP can use SHA-256 or SHA-512, though most implementations default to SHA-1 for RFC 4226 compatibility). The choice between TOTP and HOTP is primarily a UX decision — TOTP with 30-second windows is the dominant choice in production systems.
Diagnosing TOTP Failures: Time, Secret, and Encoding
The first diagnostic step is comparing the current time on the server and in the authenticator app. On the server, run date -u to get current UTC time and verify it matches actual UTC time. On a Linux server, timedatectl status shows whether NTP synchronization is active and the current time offset. If NTP is not synchronized, the offset can grow to minutes over days. A command like ntpq -p shows the current NTP server list and the measured offset for each.
To test whether the issue is time drift or secret corruption, generate the expected TOTP code server-side and compare it to what the app shows. In Node.js with speakeasy: console.log(speakeasy.totp({ secret: userSecret, encoding: 'base32' })). If the server-generated code matches what the authenticator app shows, the issue is in the verification logic (likely window too small). If the codes differ for the same timestamp, the secrets are different.
Verify the stored base32 secret length. A 20-byte random secret produces a 32-character base32 string (before padding). If the stored value is shorter, it was truncated. Check the database column type: VARCHAR(20) will silently truncate a 32-character secret. Run SELECT LENGTH(totp_secret), totp_secret FROM users WHERE id = X to inspect the stored value.
For secrets that were setup via QR code, decode the QR code to see the original otpauth:// URL. The URL format is: otpauth://totp/LABEL?secret=BASE32SECRET&issuer=ISSUER&algorithm=SHA1&digits=6&period=30. Extract the secret parameter and compare it to what is stored in the database. If they differ, the secret was corrupted during storage. A QR code decoder (camera app on any smartphone or an online tool) can decode the QR image if you have access to the original.
For users reporting that codes worked before but recently stopped working, check server clock drift history. On servers with systemd-timesyncd, run journalctl -u systemd-timesyncd | grep offset to see historical drift measurements. NTP will correct drift gradually (at most 500 ppm), but if the clock jumped by more than the NTP slew limit, timedatectl might have reset the clock abruptly. A sudden clock jump means codes valid before the jump are invalid after, and vice versa.
Fixing Time Drift, Secret Storage, and Verification Windows
For server clock drift, enable and verify NTP synchronization. On modern Linux with systemd: timedatectl set-ntp true, then verify with timedatectl status showing synchronized: yes and the System clock synchronized: yes line. On Ubuntu 20.04+, chrony is the recommended NTP daemon (apt install chrony). On AWS EC2 and similar cloud platforms, use the cloud provider's time service: the Amazon Time Sync Service at 169.254.169.123 provides sub-millisecond accuracy within the AWS network.
For Docker containers, the container inherits the host clock. Container clock drift is host clock drift. Ensure NTP runs on the host, not just in containers. For Kubernetes pods, the same principle applies — pod clocks come from the node clock. Include a clock synchronization check in your readiness probe if TOTP is business-critical: if the server clock is more than 30 seconds off, the pod should fail its readiness check and stop receiving traffic until NTP corrects the drift.
For the verification window, set window to 1 in your TOTP verification call. This accepts codes from the current 30-second step, the previous step, and the next step — a total of 90 seconds of tolerance. This is the RFC 6238 recommended value. If users on mobile devices in different time zones are still reporting failures, check that your TOTP library is using Unix timestamp (seconds since epoch) correctly — time zone does not affect the TOTP computation since it is based on absolute Unix time, but daylight saving transitions can expose bugs in libraries that use local time incorrectly.
For corrupted secrets, you must regenerate TOTP enrollment for affected users. There is no way to recover a truncated or corrupted secret — the original random bytes are gone. Identify affected users by checking where the stored secret length does not equal the expected length (32 characters for a 20-byte secret, 52 characters for a 32-byte secret), then reset their TOTP enrollment and prompt them to re-scan a new QR code on next login.
For recovery codes, store them as bcrypt hashes (not plaintext and not reversible), generate 8-10 codes of 10-16 alphanumeric characters each at enrollment, and mark each code as used after consumption. Use a constant-time comparison when verifying recovery codes. Allow each recovery code to be used exactly once. Display recovery codes to the user exactly once at enrollment and make clear they should print or store them securely — recovery codes are the only fallback when a device is lost.
Edge Cases: TOTP vs HOTP, Replay Prevention, SHA Algorithm
TOTP replay prevention requires that the server tracks which codes it has accepted within the current validity window. Without replay tracking, an attacker who intercepts a valid 6-digit code can use it multiple times within the 30-second window. RFC 6238 recommends that servers track the last used T value and reject any code with the same T, even if it would otherwise verify correctly. Store the last_used_totp_step (a Unix timestamp / 30 integer) in the database and reject any verification where the computed T equals or precedes the stored value.
Google Authenticator uses TOTP with SHA-1, 6 digits, and 30-second periods — the RFC 6238 defaults. Authy supports the same defaults but also supports cloud backup of secrets, which is a significant UX advantage when users switch devices. The cloud backup security depends on the user's Authy password. Microsoft Authenticator also supports TOTP with the same defaults. All major authenticator apps are interoperable for standard TOTP (SHA-1, 6 digits, 30 seconds) — the otpauth:// URL format is a de facto standard.
Some implementations use SHA-256 or SHA-512 instead of SHA-1 for the HMAC function in TOTP. RFC 6238 allows this but notes that most implementations use SHA-1 for compatibility. If you configure your server-side TOTP library to use SHA-256 but generate a QR code without specifying the algorithm parameter (algorithm=SHA256 in the otpauth:// URL), authenticator apps will use SHA-1 by default and generate codes that never match. Always include algorithm, digits, and period parameters explicitly in the otpauth:// URL and in your verification configuration.
The 6-digit vs 8-digit question affects both security and UX. 6-digit codes have 1,000,000 possible values, giving a brute-force probability of about 1 in 33 per attempt (since verifiers accept a window of 3 codes). 8-digit codes have 100,000,000 possible values. OWASP recommends 6-digit codes as the minimum, with 8-digit codes providing marginally better security at the cost of user friction. Most major services use 6-digit codes. Rate limiting login attempts is more impactful than increasing digit count.
Hardware security keys (FIDO2/WebAuthn) are generally preferred over TOTP for high-security accounts. TOTP secrets can be phished — an attacker can build a real-time phishing page that proxies the OTP to the real site within the 30-second validity window. FIDO2 credentials are bound to a specific origin (the site's domain) and cannot be used on a phishing site even if the user enters them. For critical infrastructure, admin accounts, and high-value accounts, recommend or require FIDO2 hardware keys over TOTP-based 2FA.
TOTP Implementation Mistakes That Break 2FA
Not testing TOTP verification before enabling 2FA for a user is a serious usability and security mistake. If TOTP verification is broken (due to a server clock issue, secret encoding bug, or window misconfiguration), enabling 2FA locks the user out of their account. Always verify that a code generated by the user's authenticator app matches what the server computes before marking 2FA as active on the account. Use a confirmation step: generate the QR code, ask the user to enter a code from the app, verify it server-side, and only then set totp_enabled = true.
Storing TOTP secrets in environment variables or in-memory caches without a fallback to database is a reliability mistake. When the application restarts, secrets cached only in memory are lost, locking all users who set up TOTP during that server's lifetime out of their accounts. Always persist TOTP secrets to a durable store immediately after generation and before displaying the QR code to the user.
Not providing account recovery when 2FA devices are lost is a support nightmare. Every 2FA implementation must include: recovery codes (one-time use backup codes generated at enrollment), an admin override process for verified account owners, and clear documentation shown to users during enrollment about how to recover access. Without recovery codes, a lost phone means a permanently locked account. Generate and display 8-10 recovery codes at enrollment, hash them before storage, and allow each to be used exactly once.
Using predictable secrets defeats TOTP security. The TOTP secret must be cryptographically random — at least 128 bits (16 bytes), preferably 160 bits (20 bytes). Using user IDs, timestamps, or sequential values as seeds for the secret is catastrophic. An attacker who knows the pattern can compute the secret for any user without database access. Always use crypto.randomBytes(20) or an equivalent CSPRNG (cryptographically secure pseudo-random number generator).
Not rate-limiting TOTP verification attempts enables brute-force attacks. A 6-digit TOTP code has 1,000,000 possible values. With a 90-second window, an attacker can test many codes per window. Implement rate limiting: maximum 5 failed TOTP attempts per user per 15 minutes, with exponential backoff and account temporary lock after 10 failures. Log all failed TOTP attempts for security monitoring — an unusual spike in TOTP failures across many accounts is an indicator of credential stuffing or session hijacking.
Secure TOTP 2FA Implementation Best Practices
Use a well-maintained TOTP library rather than implementing RFC 6238 yourself. In Node.js, speakeasy and otpauth are both widely used. In Python, pyotp is the standard choice. In Go, github.com/pquerna/otp is well-maintained. In Java, use the Google Authenticator implementation from the wstrange/googleauth library or implement via javax.crypto.Mac with HmacSHA1. These libraries handle the subtle edge cases in RFC 6238 (modular truncation of the HMAC, handling of counter overflow) that are easy to get wrong in a custom implementation.
Ensure server time synchronization is monitored as a reliability requirement, not just a nice-to-have. Include NTP synchronization status in your health check endpoint: if (timeDriftMs > 15000) return { status: 'degraded', detail: 'clock drift exceeds TOTP tolerance' }. Alert on-call engineers when NTP sync is lost or when drift exceeds 10 seconds. On cloud platforms, use the provider's internal time service (169.254.169.123 on AWS, 169.254.169.254 on GCP's metadata server) for more reliable synchronization than public NTP pools.
Implement TOTP event logging for security monitoring. Log every TOTP verification attempt with: userId, result (success/failure), timestamp, the T value used (floor(timestamp/30)), and client IP address. Do not log the TOTP token value itself. Use these logs to detect anomalies: multiple failures followed by success (potential brute force), verifications from unusual geographic locations, and the T delta (how many steps off the code was) for drift monitoring.
For mobile apps that implement TOTP verification (rather than using Google Authenticator), add a time sync check: compare the device time to a trusted time source (an NTP server or your own API) and warn users if the device clock is more than 30 seconds off. iOS and Android both allow querying the system time directly; also consider calling a time API (pool.ntp.org or time.cloudflare.com) and comparing the response to local time as a sanity check.
Security note: TOTP is vulnerable to real-time phishing attacks where an attacker proxies the code to the legitimate site within the validity window. For this reason, OWASP recommends that high-security applications implement FIDO2/WebAuthn (hardware security keys or platform authenticators) as the preferred second factor, with TOTP as a fallback for users without compatible hardware. When recommending 2FA to users, distinguish between TOTP-based apps (Google Authenticator, Authy) and FIDO2 hardware keys (YubiKey, Google Titan), and explain the phishing resistance benefit of hardware keys.
Quick fix checklist
- ✓Run timedatectl status to verify NTP is synchronized and clock drift is under 5 seconds
- ✓Set window: 1 (or equivalent) in your TOTP verification call to accept codes ±1 time step (90s tolerance)
- ✓Verify the TOTP secret in the database is exactly 32 characters (base32 of 20 bytes) — check for truncation
- ✓Test that server-generated TOTP code matches the app code: console.log(speakeasy.totp({secret, encoding: 'base32'}))
- ✓Check the database column type allows at least 256 characters for TOTP secret storage
- ✓Verify a confirmation code before enabling 2FA to catch broken verification before locking users out
- ✓Generate and display 8-10 recovery codes at enrollment and hash them before database storage
- ✓Add rate limiting: maximum 5 failed TOTP attempts per user per 15 minutes
Related guides
- → Password Hashing Best Practices
- → OAuth Token Invalid Fix
- → JWT Decoder Tool
- → Base64 Encode/Decode Tool
Frequently asked questions
Why is my TOTP code rejected even though it looks correct in the authenticator app?
The most likely cause is clock drift between your server and the user's device. TOTP codes are only valid for the 30-second window when they were generated. If your server clock is more than 30 seconds off from the device clock, the server computes a different code than the app. Run timedatectl status to verify NTP synchronization on the server. Also verify your TOTP verification uses window: 1 to accept codes from ±1 time step.
What is the difference between TOTP and HOTP?
TOTP (RFC 6238) generates codes based on the current time divided into 30-second intervals. HOTP (RFC 4226) generates codes based on a monotonically increasing counter. TOTP codes expire after 30–90 seconds. HOTP codes are valid until used, which can cause counter desynchronization if codes are generated without being submitted. TOTP is the dominant production choice because it does not require counter synchronization between app and server.
How do I fix clock drift on my server to fix TOTP errors?
Enable NTP synchronization: run timedatectl set-ntp true on Linux with systemd. Install chrony if not present: apt install chrony (Ubuntu/Debian) or yum install chrony (RHEL/CentOS). On AWS EC2, configure the Amazon Time Sync Service at 169.254.169.123 for sub-millisecond accuracy. In Docker or Kubernetes, clock synchronization runs at the host level — containers inherit the host clock. Verify sync with timedatectl status showing synchronized: yes.
What base32 encoding format should TOTP secrets use?
TOTP secrets use RFC 4648 base32 encoding, which uses the alphabet A-Z and digits 2-7, with = padding. The secret should be at least 20 bytes (160 bits) of cryptographically random data, which encodes to 32 base32 characters. Most TOTP libraries (speakeasy, pyotp) generate and encode secrets automatically. If storing manually, ensure the database column is at least VARCHAR(256) to avoid truncation, and normalize the secret to uppercase before storage and verification.
Are Google Authenticator and Authy interoperable for TOTP?
Yes. Both apps implement standard TOTP (RFC 6238) with SHA-1, 6 digits, and 30-second periods. Any QR code in the otpauth://totp/ format that omits the algorithm and period parameters will work with both apps using the defaults. The key difference is that Authy supports encrypted cloud backup of secrets, allowing recovery when a phone is lost. Google Authenticator has no cloud backup and requires manual export before switching devices.
How should I store TOTP recovery codes securely?
Generate 8-10 recovery codes of at least 10 alphanumeric characters each at enrollment, display them to the user once, and store only their bcrypt or Argon2id hashes in the database. Never store recovery codes in plaintext. Mark each code as used after a single successful use. Use constant-time comparison when verifying. Include the recovery code generation and display step in your TOTP enrollment confirmation flow so users are prompted to save them before 2FA is activated.
Is TOTP vulnerable to phishing attacks?
Yes. TOTP is vulnerable to real-time phishing where an attacker proxies the code to the legitimate site within the 30-second validity window. A convincing phishing page can capture the username, password, and TOTP code in under 10 seconds and immediately use them. FIDO2/WebAuthn hardware keys (YubiKey, Google Titan, Apple Touch ID with passkeys) are not vulnerable to this attack because they bind credentials to a specific origin URL. For high-security accounts, use FIDO2 rather than TOTP.
What TOTP time window size should I use in production?
Use window: 1 (accepting current step ±1 step) as the standard production setting per RFC 6238 recommendations. This provides 90 seconds of tolerance, which handles virtually all real-world clock drift. Using window: 0 (current step only) causes false rejections for users with any clock drift. Using window: 2 (150 seconds) is more permissive than necessary for most environments and slightly increases the window during which a captured code can be replayed.
Why does TOTP work for some users but not others on the same server?
This usually means the affected users have device clock drift rather than a server-side problem. The server clock is correct (NTP synchronized) and most users whose device clocks are accurate pass verification. Users with incorrect device clocks — common after travel, manual clock setting, or using devices that were off for extended periods — will have their codes rejected. Advise affected users to enable automatic time synchronization on their devices and open the authenticator app settings to check for a time correction option.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.