Skip to content

TOTP setup flow rejects codes with clock drift that a normal login would accept (missing valid_window) #181369

Description

@angeousta

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

  1. 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).
  2. Go to Profile → Security → enable TOTP two-factor authentication.
  3. Scan the QR code, enter the 6-digit code shown by the app.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions