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
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
| Option | Type | Default | Description |
|---|---|---|---|
issuer | str | None | None | Issuer shown in the authenticator app (defaults to the app name). |
two_factor_table | str | "twoFactor" | Model name for the plugin's table. |
totp_options | dict | None | None | TOTP group, e.g. {"digits": 6, "period": 30}. |
otp_options | dict | None | None | OTP group, e.g. {"send_otp": fn, "period": 3, "store_otp": "plain"}. |
backup_code_options | dict | None | None | Backup-code group, e.g. {"amount": 10, "length": 10}. |
skip_verification_on_enable | bool | False | Enable 2FA without requiring a first verified code. |
allow_passwordless | bool | False | Allow enabling 2FA on accounts without a password credential. |
two_factor_cookie_max_age | int | 600 | Lifetime (seconds) of the sign-in challenge cookie. |
trust_device_max_age | int | 2592000 | Lifetime (seconds) of the trusted-device cookie (30 days). |
account_lockout | dict | None | None | Lockout group (failed-attempt threshold and duration). |
Sub-option dicts use snake_case keys mirroring the TS option groups.
Endpoints
8 routes under /two-factor/:
| Method | Path |
|---|---|
| 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
| Table | Columns |
|---|---|
user | adds twoFactorEnabled |
twoFactor | secret, backupCodes, userId, verified, failedVerificationCount, lockedUntil |
Notes
Cross-runtime storage parity:
secretandbackupCodesare XChaCha20-Poly1305 encrypted exactly like TS; a row written by the TS library verifies here and vice versa./two-factor/enabletakespasswordand an optionalmethod:"totp"(the default) or"otp". With"totp"the response isjson{ "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 notwoFactorrow are needed, and the response is{"method": "otp"}."otp"needsotp_options["send_otp"](else a 400OTP_NOT_CONFIGURED);"totp"fails with a 400TOTP_NOT_CONFIGUREDwhentotp_options["disable"]is set.Calling
/two-factor/enablewith"totp"after TOTP is verified returns a 400TOTP_ALREADY_ENABLED. An unfinished enrollment is restarted in place with a new secret and new backup codes.An OTP-only account (no
twoFactorrow) can sign in with an OTP. The account lockout only applies to accounts that have atwoFactorrow.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_CHALLENGEinstead of letting the challenge stay usable.Sign-in with 2FA enabled returns
{"twoFactorRedirect": true, "twoFactorMethods": [...]}and sets the signedtwo_factorchallenge 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.