Skip to content

Two-Factor Authentication ​

Second-factor authentication via TOTP, emailed/SMS OTP and backup codes, with a short-lived two-factor cookie between the password step and the code step, trusted devices, and optional account lockout. Mirrors the TS twoFactor() plugin.

Enable ​

python
from better_auth import BetterAuth
from better_auth.plugins_ext import TwoFactorPlugin

auth = BetterAuth(
    secret="a-strong-32-character-minimum-secret",
    plugins=[TwoFactorPlugin(issuer="Example")],
)

Options ​

OptionTypeDefaultDescription
issuerstr | NoneNoneIssuer shown in the authenticator app (defaults to the app name).
two_factor_tablestr"twoFactor"Model name for the plugin's table.
totp_optionsdict | NoneNoneTOTP group, e.g. {"digits": 6, "period": 30}.
otp_optionsdict | NoneNoneOTP group, e.g. {"send_otp": fn, "period": 3, "store_otp": "plain"}.
backup_code_optionsdict | NoneNoneBackup-code group, e.g. {"amount": 10, "length": 10}.
skip_verification_on_enableboolFalseEnable 2FA without requiring a first verified code.
allow_passwordlessboolFalseAllow enabling 2FA on accounts without a password credential.
two_factor_cookie_max_ageint600Lifetime (seconds) of the sign-in challenge cookie.
trust_device_max_ageint2592000Lifetime (seconds) of the trusted-device cookie (30 days).
account_lockoutdict | NoneNoneLockout group (failed-attempt threshold and duration).

Sub-option dicts use snake_case keys mirroring the TS option groups.

Endpoints ​

8 routes under /two-factor/:

MethodPath
POST/two-factor/enable
POST/two-factor/disable
POST/two-factor/get-totp-uri
POST/two-factor/verify-totp
POST/two-factor/send-otp
POST/two-factor/verify-otp
POST/two-factor/verify-backup-code
POST/two-factor/generate-backup-codes

The TS server-only endpoints /totp/generate and /two-factor/view-backup-codes are not mounted as HTTP routes; they are exposed as plain async methods on the plugin instance: generate_totp_code(secret) and view_backup_codes(user_id), following the Email OTP precedent.

Schema ​

TableColumns
useradds twoFactorEnabled
twoFactorsecret, backupCodes, userId, verified, failedVerificationCount, lockedUntil

Notes ​

  • Cross-runtime storage parity: secret and backupCodes are XChaCha20-Poly1305 encrypted exactly like TS; a row written by the TS library verifies here and vice versa.

  • /two-factor/enable takes password and an optional method: "totp" (the default) or "otp". With "totp" the response is

    json
    {
      "method": "totp",
      "totpURI": "otpauth://...",
      "backupCodes": ["..."]
    }

    and 2FA turns on once the first code is verified (or right away with skip_verification_on_enable=True). With "otp" 2FA turns on at once for email or SMS codes, no authenticator app and no twoFactor row are needed, and the response is {"method": "otp"}. "otp" needs otp_options["send_otp"] (else a 400 OTP_NOT_CONFIGURED); "totp" fails with a 400 TOTP_NOT_CONFIGURED when totp_options["disable"] is set.

  • Calling /two-factor/enable with "totp" after TOTP is verified returns a 400 TOTP_ALREADY_ENABLED. An unfinished enrollment is restarted in place with a new secret and new backup codes.

  • An OTP-only account (no twoFactor row) can sign in with an OTP. The account lockout only applies to accounts that have a twoFactor row.

  • When a challenge runs out of attempts it is cancelled. If the cancel fails, the request fails with a 500 FAILED_TO_INVALIDATE_TWO_FACTOR_CHALLENGE instead of letting the challenge stay usable.

  • Sign-in with 2FA enabled returns {"twoFactorRedirect": true, "twoFactorMethods": [...]} and sets the signed two_factor challenge cookie instead of a session.

Changed in 1.1

In 1.0, calling /two-factor/enable again replaced a verified TOTP secret. It now returns TOTP_ALREADY_ENABLED: disable 2FA first to enroll a new authenticator. See Upgrade from 1.0.

MIT licensed · API-compatible with better-auth