Skip to content

TOTP (two-factor authentication)

TOTP is enabled by setting totp_config: TotpConfig on LitestarAuthConfig. Routes are mounted under the auth prefix, e.g. /auth/2fa/....

Enrollment (two-step)

  1. POST .../2fa/enable — returns a secret, otpauth URI, and short-lived enrollment_token (JWT). The secret is not stored until confirmation.
  2. POST .../2fa/enable/confirm — sends enrollment_token + TOTP code; on success the secret is persisted and the response returns one-time recovery codes.

By default totp_enable_requires_password=True, so step 1 also requires the current password (step-up). Because the /2fa/enable response necessarily includes the plaintext TOTP secret and otpauth URI for QR-code rendering, production deployments must serve this route only over HTTPS.

Enrollment-token confidentiality

The enrollment_token does not carry the freshly generated TOTP secret. It carries only short-lived lookup claims (sub, jti, and an encoding marker). The secret is stored server-side in totp_enrollment_store, encrypted first with user_manager_security.totp_secret_keyring — the same keyring used to encrypt the persisted secret. The one-key totp_secret_key shortcut remains available for deployments with a single active Fernet key. In production, totp_secret_keyring or totp_secret_key is required together with totp_enrollment_store; plaintext, process-local enrollment state is only created automatically when the owning config/controller explicitly sets unsafe_testing=True.

Each /2fa/enable call replaces any previous pending enrollment for that user, and /2fa/enable/confirm atomically consumes the matching jti. A stale token, reused token, token from an older /enable, or token consumed by an invalid code cannot be confirmed later.

Successful confirmation returns TotpConfirmEnableResponse with enabled: true and recovery_codes. The plaintext recovery codes are issued only in that response; the user model stores HMAC-SHA-256 lookup digests mapped to Argon2 hashes in recovery_codes. Generated recovery codes are 28 lowercase hex characters (112 bits).

Recovery-code rotation

  • POST .../2fa/recovery-codes/regenerate — authenticated users can replace the active recovery-code set. The response returns the new plaintext recovery_codes once; old recovery codes stop working immediately.

By default this route follows the same password step-up policy as enrollment: when totp_enable_requires_password=True, send TotpRegenerateRecoveryCodesRequest with current_password. A wrong password returns the same LOGIN_BAD_CREDENTIALS failure shape as login. Because recovery-code rotation is also protected by TOTP step-up for enrolled users, the body can include totp_code; when password step-up is disabled, totp_code is the interactive proof for callers that do not already have a recent TOTP marker.

Login completion

When a login requires a second factor, the client finishes with:

  • POST .../2fa/verify — pending token + TOTP code, or pending token + an unused recovery code in the same code field. A recovery code is consumed on successful login and cannot be reused.

Pending login JWTs use a JTI denylist internally. In production, configure TotpConfig.totp_pending_jti_store on the plugin-managed path or pass pending_jti_store to create_totp_controller manually. Missing pending-token replay storage now fails closed unless the owning config/controller explicitly sets unsafe_testing=True.

Pending login JWTs are client-bound by default. With TotpConfig.totp_pending_require_client_binding=True, /login adds SHA-256 fingerprint claims for the trusted-proxy-aware client IP (cip) and User-Agent (uaf), and /2fa/verify recomputes them before accepting either a TOTP code or recovery code. A mismatch returns the same 400 TOTP_PENDING_BAD_TOKEN shape as an expired or malformed pending token. Set the flag to False only when your deployment accepts cross-client pending-token replay; the controller logs that weaker posture at factory time.

Disable

  • POST .../2fa/disable — requires a valid current TOTP code or an unused recovery code. A recovery-code disable consumes the matching code, clears the TOTP secret, and clears any remaining recovery-code hashes.

Recovery-code lookup secret and schema migration

Production totp_config deployments must set UserManagerSecurity.totp_recovery_code_lookup_secret to a distinct CSPRNG-generated secret that clears validate_production_secret. The user table stores recovery codes in recovery_codes (dict[str, str] | None), where each key is a server-side HMAC-SHA-256 lookup digest and each value is the Argon2 hash of the code.

Migration checklist:

  1. Configure totp_recovery_code_lookup_secret on UserManagerSecurity.
  2. Run a data migration that drops or nulls the legacy recovery_codes_hashes column and adds nullable JSON recovery_codes.
  3. Deploy the application.
  4. Tell users with TOTP enabled to log in using their authenticator app and regenerate recovery codes at /auth/2fa/recovery-codes/regenerate.

Replay protection

Production deployments should configure totp_used_tokens_store so codes cannot be reused. Without it, the library fails fast unless the owning config/controller explicitly opts into unsafe_testing=True.

When the same async Redis client should back auth rate limiting plus the TOTP Redis stores, use the shared-client recipe in Configuration. That is the maintained RedisAuthPreset flow for build_rate_limit_config(), build_totp_enrollment_store(), build_totp_pending_jti_store(), and build_totp_used_tokens_store() together. Keep manual totp_enrollment_store / pending_jti_store / totp_used_tokens_store wiring as the direct path when you intentionally use separate backends or bespoke key prefixes.

The three production stores are still distinct even in the shared-client recipe:

  • totp_enrollment_store stores pending enrollment secrets and enforces latest-only, single-use confirmation.
  • totp_pending_jti_store prevents pending-login JWT replay.
  • totp_used_tokens_store prevents consumed TOTP-code replay.

For pytest-driven plugin tests, see the testing guide. Under unsafe_testing=True, the plugin can run without totp_used_tokens_store, but that is a single-process testing convenience rather than a production-safe replay-protection setup.

Algorithm defaults to SHA256 (totp_algorithm). Supported algorithms are SHA256 and SHA512.

  • Configuration — Redis-backed production recipe for rate limiting plus the TOTP Redis stores.
  • TOTPTotpConfig.
  • TOTP API — helpers and types.
  • Manager API — manager hooks for secrets and lifecycle.
  • Testing plugin-backed apps — explicit unsafe_testing, request-scoped sessions, and store-isolation boundaries.