The problem
When enrolling a new TOTP authenticator during MFA setup (Profile → Security → Two-factor authentication), a code generated by an authenticator app with even a small clock drift (~15-25 seconds) from the server is rejected as invalid_code — even though the exact same code, submitted a few seconds later or with a synced clock, would be valid.
This is inconsistent with normal login-time TOTP validation, which already tolerates one time-step of drift.
Root cause
In homeassistant/auth/mfa_modules/totp.py:
-
TotpAuthModule._validate_2fa (used during actual login) calls:
return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
→ tolerates ±1 time step (~30s) of clock drift.
-
TotpSetupFlow.async_step_init (used during initial enrollment) calls:
verified = await self.hass.async_add_executor_job(
pyotp.TOTP(self._ota_secret).verify, user_input["code"]
)
→ no valid_window passed, defaults to 0 (exact match only, no drift tolerance).
So the one-time enrollment step is stricter than every subsequent login, which is surprising and leads to confusing "it's not working" reports that are actually just phone clock drift, indistinguishable from a real bug from the user's perspective (no error is obviously actionable — it just silently fails to confirm).
To reproduce
- Set your phone/authenticator app's clock to be ~20-25 seconds off from real time (or just have a phone with slight drift, which is common).
- Go to Profile → Security → enable TOTP two-factor authentication.
- Scan the QR code, enter the 6-digit code shown by the app.
- Observe: the code is rejected, with no indication that clock drift is the cause.
Expected behavior
The setup flow should use the same valid_window=1 tolerance as the login-time validation (_validate_2fa), for consistent behavior between enrollment and everyday use.
Suggested fix
In TotpSetupFlow.async_step_init, change:
pyotp.TOTP(self._ota_secret).verify, user_input["code"]
to:
pyotp.TOTP(self._ota_secret).verify, user_input["code"], None, 1
(or via a functools.partial/lambda to pass valid_window=1 as a keyword argument through async_add_executor_job), matching _validate_2fa.
Environment
- Home Assistant Core 2026.2.3 (pip/venv installation, no Supervisor)
- Python 3.13.5
- Reproduced by simulating the exact WebSocket
auth/setup_mfa flow the frontend uses, confirming the server-side invalid_code rejection was purely due to a ~24s clock offset on the client generating the code — a code generated using the server's exact timestamp was accepted immediately.
The problem
When enrolling a new TOTP authenticator during MFA setup (Profile → Security → Two-factor authentication), a code generated by an authenticator app with even a small clock drift (~15-25 seconds) from the server is rejected as
invalid_code— even though the exact same code, submitted a few seconds later or with a synced clock, would be valid.This is inconsistent with normal login-time TOTP validation, which already tolerates one time-step of drift.
Root cause
In
homeassistant/auth/mfa_modules/totp.py:TotpAuthModule._validate_2fa(used during actual login) calls:→ tolerates ±1 time step (~30s) of clock drift.
TotpSetupFlow.async_step_init(used during initial enrollment) calls:→ no
valid_windowpassed, defaults to0(exact match only, no drift tolerance).So the one-time enrollment step is stricter than every subsequent login, which is surprising and leads to confusing "it's not working" reports that are actually just phone clock drift, indistinguishable from a real bug from the user's perspective (no error is obviously actionable — it just silently fails to confirm).
To reproduce
Expected behavior
The setup flow should use the same
valid_window=1tolerance as the login-time validation (_validate_2fa), for consistent behavior between enrollment and everyday use.Suggested fix
In
TotpSetupFlow.async_step_init, change:to:
(or via a
functools.partial/lambda to passvalid_window=1as a keyword argument throughasync_add_executor_job), matching_validate_2fa.Environment
auth/setup_mfaflow the frontend uses, confirming the server-sideinvalid_coderejection was purely due to a ~24s clock offset on the client generating the code — a code generated using the server's exact timestamp was accepted immediately.