Connect authenticator enrollment, sign-in challenges, and recovery codes while preserving server session checks.

Two-factor authentication in BTST needs a server challenge, a matching browser client, and a page that completes the challenge. Enabling a settings card supplies only the interface. This guide connects authenticator-app codes and recovery codes to an existing email-and-password integration.
The examples target @btst/stack@3.1.2, @btst/better-auth-ui@2.0.1, and better-auth@1.6.16. Start with the BTST auth integration if your handler, client, providers, and catch-all pages are not mounted yet. Preserve that setup when adding the fragments below.
Add this plugin to the existing betterAuth({ plugins: [...] }) configuration:
import { twoFactor } from "better-auth/plugins/two-factor";
export const twoFactorPlugin = twoFactor({
issuer: "Example App",
});
Keep your existing database, secret, origin checks, rate limits, and email/password configuration. Generate and review the plugin schema using tooling compatible with your installed Better Auth release, then apply it through your normal migration process. Enrollment needs the two-factor storage and user field in that schema.
The 1.6.16 implementation returns a TOTP URI and backup codes from enrollment. With the default policy, the user must verify a generated code before twoFactorEnabled becomes true. Keep this check enabled: successfully displaying setup information does not prove the authenticator works.
This article deliberately uses the released API rather than copying newer options from the moving upstream two-factor documentation. Check your installed version before extending the configuration to other enrollment methods.
Add the matching client plugin to the auth client already passed to BTST:
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [twoFactorClient()],
});
Merge this UI fragment into StackProvider's existing overrides.auth:
import type { AuthPluginOverrides } from "@btst/better-auth-ui/client";
export const twoFactorUI = {
twoFactor: ["totp"],
} satisfies Partial<AuthPluginOverrides>;
These fragments omit your other client plugins and provider options for brevity; retain them. With default route paths and a /p mount, account security is /p/account/security, the challenge page is /p/auth/two-factor, and backup-code recovery is /p/auth/recover-account. Mount both the auth and account plugins so these pages exist.
The released security page shows its two-factor card only when the option is enabled and the user has a linked credential account. A social-only account therefore will not see this card. Supporting passwordless enrollment needs an independently designed flow; turning on the server's passwordless option does not change this UI condition.
From account security, enter the current password and save the recovery codes somewhere private. The companion sends the TOTP URI to its challenge page during setup. Treat that URI as a credential: it includes the authenticator secret. Exclude the setup URL and its query parameters from analytics, logs, screenshots, and support recordings. Never use a public QR-code service to render it.
After scanning the secret into an authenticator, submit a current code. Then sign out and sign in with the password again. The companion sign-in form checks twoFactorRedirect and navigates to the challenge page before its normal success transition. A custom sign-in form must preserve this distinction.
In the pinned server release, an outstanding challenge removes the provisional session. A protected server request should remain unauthenticated until verification succeeds. Hooks reading newSession must tolerate it being absent during this transition.
| Check | Expected result |
|---|---|
| Start enrollment with a wrong password | Request rejected; no new authenticator enrolled |
| Start valid enrollment but stop before verification | Two-factor protection is not yet enabled |
| Submit an invalid authenticator code | Verification rejected |
| Sign in after completing enrollment | Challenge precedes an authenticated session |
| Use one recovery code | Sign-in completes; that code cannot be used again |
| Disable protection | Password and server policy still apply |
The release's automatic sign-in challenge applies to email/password, username, and phone-number sign-in endpoints. Do not assume it also wraps OAuth, magic links, email OTP, or passkeys. Test every enabled sign-in method against your application's policy before describing an account as requiring a second factor everywhere. The separate passkey guide covers that integration.
Also test trusted-device behavior on a fresh browser profile. A trusted browser can intentionally skip a later challenge; it is unsuitable for testing the ordinary untrusted-device path.
The snippets were type-checked and the pinned handler was exercised with an in-memory adapter for enrollment, challenge, recovery, and rejection cases. Physical authenticator enrollment, real migrations, and a complete browser integration were not exercised for this guide. Continue with the BTST auth documentation and session-management guide for server access checks after sign-in.