開發者最常犯的 5 個 TOTP 錯誤(以及 2026 年的修正方式)

2026 年 TOTP 驗證碼常失敗?一次看懂 5 個常見錯誤:時鐘漂移、Base32 secret、RFC 6238 參數不一致、驗證邏輯薄弱,以及每項對應修正(含 Python/JavaScript 範例)。

開發者最常犯的 5 個 TOTP 錯誤(以及 2026 年的修正方式)

TOTPtime-based one-time password)仍是數百萬應用程式雙重驗證的核心;到了 2026 年,它也常是 passkey 登入流程背後的備援 2FA。六位數、30 秒、看似簡單。但只要實作細節出錯,就可能在無聲中讓整批使用者無法登入。

如果你遇到 「invalid TOTP code」,幾乎可以確定是下列五個錯誤之一。每節都包含症狀、根因與可直接落地的修補方式,並附上 Python 與 JavaScript 程式碼。你也可以用我們免費的 TOTP Authenticator 工具 立即驗證修補結果(支援 SHA-1/256/512、6–8 位數、自訂週期)。

5 大 TOTP 錯誤總覽

#錯誤症狀快速修正
1時鐘漂移驗證碼間歇性失敗伺服器使用 NTP 同步,驗證允許 ±1 step
2Base32 格式錯誤所有驗證碼穩定失敗從 otpauth URI 取出並以 Base32 解碼 secret
3RFC 6238 參數不一致客戶端與伺服器永遠對不上雙方確認 digits、period、algorithm
4Provisioning URI 錯誤新用戶無法掃碼或完成設定產生 QR 前先驗證 otpauth:// URI
5驗證邏輯過弱步進邊界鎖帳;有暴力破解風險加上 ±1 window、重放保護、速率限制

錯誤 1 — 時鐘漂移(伺服器與驗證器不同步)

症狀: 驗證碼偶發失敗。使用者回報「有時可以有時不行」。QA 在本機可重現,但 CI 或容器環境又不穩定。

原因: TOTP 會以 Unix 時間除以時間步進(預設 30 秒)得到目前驗證碼。伺服器與客戶端只要相差 30–60 秒,就會讓正確驗證碼失敗。容器與 VM 在休眠或 live migration 後特別容易漂移。

修正方式:

  • 讓伺服器與 NTP(Network Time Protocol) 同步。Linux 可用:systemctl enable --now chronyd
  • 驗證端加入 ±1 時間步進 window。可接受前後最多 30 秒內的碼,兼顧真實世界漂移與攻擊面控制。
  • 失敗日誌紀錄伺服器時間戳與 step counter,以便快速定位系統性漂移。
import pyotp

def verify_totp_with_window(secret: str, code: str, window: int = 1) -> bool:
    """
    Verify a TOTP code with clock drift tolerance.
    window=1 accepts codes from the previous and next time step (+-30 seconds).
    """
    totp = pyotp.TOTP(secret)
    return totp.verify(code, valid_window=window)

# Usage
secret = "JBSWY3DPEHPK3PXP"  # Base32-encoded secret
user_code = "123456"

if verify_totp_with_window(secret, user_code):
    print("Code accepted")
else:
    print("Code rejected -- check clock sync")
import { TOTP } from 'otpauth';

function verifyTOTPWithWindow(secret, code, window = 1) {
  // window=1 accepts codes +-1 time step (+-30 seconds)
  const totp = new TOTP({ secret, digits: 6, period: 30 });

  // validate() returns how many steps off the code is, or null if invalid
  const delta = totp.validate({ token: code, window });
  return delta !== null;
}

// Usage
const secret = 'JBSWY3DPEHPK3PXP';
const userCode = '123456';

if (verifyTOTPWithWindow(secret, userCode)) {
  console.log('Code accepted');
} else {
  console.log('Code rejected -- check clock sync');
}

⚠️ window 要小: ±1 代表約 90 秒寬限。若用 ±2,攻擊面會擴到 5 分鐘。正式環境建議固定 window=1。

錯誤 2 — Secret 格式錯誤(Base32 vs Hex、大小寫、Padding)

症狀: 所有用戶都穩定失敗。驗證器 App 有產生碼,但永遠對不上;換 App 也無解。

原因: TOTP secret 在 provisioning URI 內是 Base32。常見誤用是把 Base32 字串當原始 ASCII、做 hex decode,或不正確移除 = padding。結果就是完全不同位元序列,所有碼都會錯。

修正方式: otpauth:// URI 裡的 secret= 一律視為 Base32。先去空白、轉大寫,再以 Base32 解碼驗證。

import base64
import pyotp

def parse_secret(secret_str: str) -> str:
    """
    Normalize and validate a TOTP secret string.
    Base32 only uses A-Z and 2-7, plus optional = padding.
    """
    # Strip spaces and uppercase (Base32 is case-insensitive)
    normalized = secret_str.replace(" ", "").upper()

    # Add padding if missing (Base32 strings must be multiples of 8 chars)
    padding_needed = len(normalized) % 8
    if padding_needed:
        normalized += "=" * (8 - padding_needed)

    # Validate it decodes correctly as Base32
    try:
        base64.b32decode(normalized)
    except Exception as e:
        raise ValueError(f"Secret is not valid Base32: {e}")

    return normalized

# Correct usage
secret = parse_secret("JBSWY3DP EHPK3PXP")  # strips spaces, fixes padding
totp = pyotp.TOTP(secret)
print(totp.now())  # generates the correct code

# Wrong: do NOT decode from hex or cast to raw bytes
# secret_bytes = bytes.fromhex(secret_hex)  # this is wrong!
function parseSecret(secretStr) {
  // Normalize: strip whitespace and uppercase
  const normalized = secretStr.replace(/\s/g, '').toUpperCase();

  // Validate: Base32 only uses A-Z and 2-7
  if (!/^[A-Z2-7]+=*$/.test(normalized)) {
    throw new Error(`Invalid Base32 secret: "${normalized}"`);
  }

  return normalized;
}

// Correct usage
const secret = parseSecret('JBSWY3DP EHPK3PXP');
console.log(secret); // "JBSWY3DPEHPK3PXP"

// Wrong: do NOT treat the secret as UTF-8 or hex
// const secretBytes = Buffer.from(secret, 'hex');  // wrong!

快速檢查:合法 Base32 secret 只會有 A–Z2–7(外加可選 = padding)。若出現小寫、2–7 以外數字或符號,多半是產生或儲存流程出錯。

錯誤 3 — RFC 6238 參數不一致(Digits、Period、Algorithm)

症狀: 客戶端與伺服器都能產生驗證碼,但永遠不相符。換不同驗證器 App 有時可用、有時無效。

原因: RFC 6238 定義三個可調參數。任一項不一致都會讓碼永遠對不上:

  • digits — 碼長。預設 6。若伺服器產 8 碼、客戶端預期 6 碼,必然失敗。
  • period — 秒級步進。預設 30。60 秒步進與 30 秒會產生完全不同碼。
  • algorithm — HMAC 雜湊。預設 SHA-1。Google Authenticator/Authy 預設 SHA-1;改 SHA-256 會破壞多數 App 相容性。
import pyotp

# Correct: use RFC 6238 defaults for maximum compatibility
totp = pyotp.TOTP(
    secret,
    digits=6,     # 6 digits -- universal default
    interval=30,  # 30-second period -- standard
    # SHA-1 is pyotp's default -- don't change unless you control both ends
)

# Mismatch example: server uses 8 digits, client expects 6
totp_server = pyotp.TOTP(secret, digits=8)
totp_client = pyotp.TOTP(secret, digits=6)

server_code = totp_server.now()  # e.g., "12345678"
client_code = totp_client.now()  # e.g., "345678" -- last 6 digits of 8
print(server_code == client_code)  # False -- they will never match
import { TOTP } from 'otpauth';

// Correct: explicit standard parameters
const totp = new TOTP({
  secret,
  digits: 6,        // 6 digits -- universal default
  period: 30,       // 30-second period
  algorithm: 'SHA1' // SHA-1 -- most compatible
});

// Mismatch example: different algorithm breaks compatibility
const totpServer = new TOTP({ secret, digits: 8, period: 30, algorithm: 'SHA256' });
const totpClient = new TOTP({ secret, digits: 6, period: 30, algorithm: 'SHA1' });

console.log(totpServer.generate()); // e.g., "87654321"
console.log(totpClient.generate()); // e.g., "654321" -- different, will never match

💡 建議: 除非你完全掌控客戶端與伺服器,否則請使用 RFC 6238 預設:6 digits、30 秒、SHA-1。非標準設定會影響 Google Authenticator、Authy、1Password 與多數硬體 token。

錯誤 4 — Provisioning 資料錯誤(otpauth URI / QR 問題)

症狀: 新用戶掃 QR 成功卻無法登入;舊用戶(改版前註冊)正常。問題只影響新註冊。

原因: 設定 TOTP 時,你會產生 otpauth://totp/... URI 並顯示為 QR。該 URI 內含 label、issuer、secret 與 TOTP 參數。任何拼字錯誤、secret 截斷或參數錯置,都會讓驗證器儲存錯誤設定;通常要到登入那一刻才會爆雷。

URI 格式如下:

otpauth://totp/{LABEL}?secret={BASE32_SECRET}&issuer={ISSUER}&algorithm=SHA1&digits=6&period=30
import pyotp
import urllib.parse

def generate_and_validate_totp_uri(secret: str, account: str, issuer: str) -> str:
    """
    Generate a TOTP provisioning URI and validate it round-trips correctly.
    Always validate before encoding to a QR code.
    """
    totp = pyotp.TOTP(secret)
    uri = totp.provisioning_uri(name=account, issuer_name=issuer)

    # Parse the URI back and verify the secret is intact
    parsed = urllib.parse.urlparse(uri)
    params = urllib.parse.parse_qs(parsed.query)

    stored_secret = params.get("secret", [None])[0]
    if stored_secret != secret:
        raise ValueError(f"Secret mismatch in URI: expected {secret}, got {stored_secret}")

    return uri

# Usage
uri = generate_and_validate_totp_uri(
    secret="JBSWY3DPEHPK3PXP",
    account="user@example.com",
    issuer="YourApp"
)
print(uri)
# otpauth://totp/YourApp:user%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=YourApp
import { TOTP, URI } from 'otpauth';

function generateAndValidateTOTPUri(secret, account, issuer) {
  // Generate the otpauth:// URI
  const totp = new TOTP({ issuer, label: account, secret });
  const uri = totp.toString();

  // Validate: parse back and confirm the secret survived
  const parsed = URI.parse(uri);
  if (parsed.secret.base32 !== secret) {
    throw new Error(`Secret mismatch in URI: expected ${secret}, got ${parsed.secret.base32}`);
  }

  return uri;
}

// Usage
const uri = generateAndValidateTOTPUri(
  'JBSWY3DPEHPK3PXP',
  'user@example.com',
  'YourApp'
);
console.log(uri);
// otpauth://totp/YourApp:user%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=YourApp

⚠️ 上線前用真實 App 測: 同時用 Google Authenticator 與 Authy 掃你的 QR。兩者都能產生伺服器可接受的碼,才能算 provisioning 流程正確。

錯誤 5 — 驗證邏輯過於天真(無 Window、重放保護、限速)

症狀: 使用者在 30 秒切換邊界輸入時被鎖;或資安團隊指出 6 位數碼在無鎖定下可被暴力嘗試。

原因: 只驗當前 step 的 verifier,會拒絕剛好跨 step 的合法碼。沒有重放保護時,攻擊者只要截到有效碼,在 30 秒內可重複使用。沒有限速時,1,000,000 個 6 位數組合可很快被嘗試。

import pyotp
import time

# In production, use Redis -- in-memory won't survive restarts
used_codes: set = set()
attempt_counts: dict = {}

def verify_totp_secure(
    secret: str,
    code: str,
    user_id: str,
    window: int = 1,
    max_attempts: int = 5,
    lockout_seconds: int = 300
) -> dict:
    """
    Secure TOTP verification with:
    1. Clock window (+-1 step tolerance)
    2. Replay protection (each code accepted once per step)
    3. Rate limiting (lock after N failed attempts)
    """
    now = int(time.time())

    # 1. Rate limiting check
    record = attempt_counts.get(user_id, {"count": 0, "reset_at": now + lockout_seconds})
    if record["count"] >= max_attempts and now < record["reset_at"]:
        return {"success": False, "error": "Too many attempts. Try again later."}

    # 2. Replay protection
    current_step = now // 30
    code_key = f"{user_id}:{code}:{current_step}"
    if code_key in used_codes:
        return {"success": False, "error": "Code already used."}

    # 3. Verify with clock window
    totp = pyotp.TOTP(secret)
    if totp.verify(code, valid_window=window):
        used_codes.add(code_key)
        attempt_counts.pop(user_id, None)  # Reset on success
        return {"success": True}

    # Increment failure counter
    attempt_counts[user_id] = {
        "count": record["count"] + 1,
        "reset_at": record["reset_at"]
    }
    return {"success": False, "error": "Invalid code."}
import { TOTP } from 'otpauth';

// In production, use Redis -- in-memory won't survive restarts
const usedCodes = new Set();
const attemptCounts = new Map();

function verifyTOTPSecure(secret, code, userId, {
  window = 1,
  maxAttempts = 5,
  lockoutSeconds = 300
} = {}) {
  const now = Math.floor(Date.now() / 1000);

  // 1. Rate limiting check
  const record = attemptCounts.get(userId) ?? { count: 0, resetAt: now + lockoutSeconds };
  if (record.count >= maxAttempts && now < record.resetAt) {
    return { success: false, error: 'Too many attempts. Try again later.' };
  }

  // 2. Replay protection
  const currentStep = Math.floor(now / 30);
  const codeKey = `${userId}:${code}:${currentStep}`;
  if (usedCodes.has(codeKey)) {
    return { success: false, error: 'Code already used.' };
  }

  // 3. Verify with clock window
  const totp = new TOTP({ secret, digits: 6, period: 30 });
  const delta = totp.validate({ token: code, window });

  if (delta !== null) {
    usedCodes.add(codeKey);
    attemptCounts.delete(userId);
    return { success: true };
  }

  attemptCounts.set(userId, { count: record.count + 1, resetAt: record.resetAt });
  return { success: false, error: 'Invalid code.' };
}

💡 正式環境提醒: 以 Redis 取代記憶體內 SetMap,才能跨重啟與多實例持久化。對已使用碼設約 60 秒 TTL(2 個時間步進)可控制記憶體使用。

不想從零做?Authgear 已內建 TOTP 註冊、驗證、重放保護與限速。可參考 Authgear 如何實作 TOTP

加碼清單:快速排查「Invalid TOTP Code」

  • 伺服器時鐘已 NTP 同步
  • 驗證使用 ±1 step window
  • Secret 以 Base32 儲存與解碼
  • TOTP 參數符合 RFC 6238(6 位、30 秒、SHA-1)
  • Provisioning URI 與伺服器參數一致
  • 重放保護:每個碼在每個 step 只接受一次
  • 速率限制:N 次失敗後鎖定帳號

想更快定位?用 Authgear TOTP Authenticator 即時測 secret 與參數——先確認客戶端與伺服器產生同一組碼再上線。

若你想更深入理解 TOTP 原理,可閱讀 什麼是 TOTP 與其運作方式。若你正在評估 passkeys 作為比 TOTP 更強的替代方案,也可參考 Passkeys vs Passwords:Passkeys 更安全嗎?