BETTER-AUTH. UI
Plugins

Two Factor

Add a second step after the password: authenticator codes, emailed codes, and backup codes.

The two-factor plugin adds a second step to password sign-in. Better Auth withholds the session until that step succeeds, answering the sign-in request with { twoFactorRedirect: true, twoFactorMethods } instead.

It contributes:

  • A <TwoFactorChallenge /> view at /auth/two-factor covering authenticator codes, emailed codes, backup codes, and "trust this device"
  • A <TwoFactorSettings /> card in security settings for enrolling, showing the QR code, and managing backup codes
  • Mutation hooks for every two-factor endpoint (useEnableTwoFactor, useDisableTwoFactor, useVerifyTotp, useSendTwoFactorOtp, useVerifyTwoFactorOtp, useVerifyBackupCode, useGenerateBackupCodes, useGetTotpUri)

The copied sign-in forms detect twoFactorRedirect for you and route to the challenge with redirectTo preserved.

Better Auth does not apply two-factor to passwordless sign-in. Magic link, email OTP, passkeys, and OAuth all bypass the challenge: the second factor only guards password (and username) sign-in.

Setup

Install the Better Auth plugin

Add the 2FA plugin to your server config. Wire otpOptions.sendOTP if you want to offer emailed codes as a second factor:

lib/auth.ts
import { betterAuth } from "better-auth"
import { twoFactor } from "better-auth/plugins"

export const auth = betterAuth({
  // ...
  plugins: [
    twoFactor({ 
      issuer: "My App", 
      otpOptions: { 
        sendOTP: async ({ user, otp }) => { 
          // Email `otp` to `user.email`.
        } 
      } 
    }) 
  ]
})

Migrate your database

The plugin adds a twoFactor table and a twoFactorEnabled field on user:

npx @better-auth/cli generate
npx @better-auth/cli migrate

Install the matching client plugin

lib/auth-client.ts
import { createAuthClient } from "better-auth/react"
import { twoFactorClient } from "better-auth/client/plugins"

export const authClient = createAuthClient({
  plugins: [twoFactorClient()] 
})

Leave twoFactorPage and onTwoFactorRedirect unset: the copied components handle the redirect themselves and keep redirectTo intact, while twoFactorPage forces a full page reload.

Install the UI plugin

npx shadcn@latest add @better-auth-ui/two-factor

This drops the following into your codebase:

  • src/lib/auth/two-factor-plugin.ts: the twoFactorPlugin() factory
  • src/lib/auth/use-sign-in-continuation.ts: shared post-sign-in handler
  • src/lib/auth/two-factor-methods.ts: local redirect metadata storage and validation
  • src/lib/auth/use-two-factor-password.ts: decides whether to ask for a password
  • src/components/auth/otp-field.tsx: the shared code input
  • src/components/auth/two-factor/two-factor-challenge.tsx: the challenge view
  • src/components/auth/two-factor/two-factor-settings.tsx: the settings card
  • src/components/auth/two-factor/*-dialog.tsx: enable, disable, and regenerate dialogs
  • src/components/auth/two-factor/backup-codes.tsx: the one-time code display

The registry also refreshes the email/password and username sign-in forms so both detect the two-factor redirect.

Register the plugin

components/providers.tsx
import { twoFactorPlugin } from "@/lib/auth/two-factor-plugin"
import { AuthProvider } from "@/components/auth/auth-provider"

<AuthProvider
  authClient={authClient}
  navigate={navigate}
  plugins={[twoFactorPlugin()]} 
>
  {children}
</AuthProvider>

Allow the new view path

routes/auth/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, notFound } from "@tanstack/react-router"

import { Auth } from "@/components/auth/auth"
import { twoFactorPlugin } from "@/lib/auth/two-factor-plugin"

export const Route = createFileRoute("/auth/$path")({
  beforeLoad({ params: { path } }) {
    if (
      !Object.values({
        ...viewPaths.auth,
        ...twoFactorPlugin().viewPaths?.auth 
      }).includes(path)
    ) {
      throw notFound()
    }
  },
  component: AuthPage
})

function AuthPage() {
  const { path } = Route.useParams()
  return <Auth path={path} />
}

The sign-in flow

email/password or username/password

{ twoFactorRedirect: true, twoFactorMethods: ["totp", "otp"] }

/auth/two-factor?redirectTo=…
    ↓ authenticator code, emailed code, or backup code
authenticated session

The method names ride along in session storage: names only, never a code or token. The two-factor cookie that authorizes the challenge stays HTTP-only, exactly as Better Auth set it.

Building a custom sign-in form? Check for the redirect yourself:

import {
  isTwoFactorRedirect,
  storeTwoFactorMethods
} from "@/lib/auth/two-factor-methods"

const { mutate: signInEmail } = useSignInEmail(authClient, {
  onSuccess: (data) => {
    if (isTwoFactorRedirect(data)) {
      storeTwoFactorMethods(data.twoFactorMethods)
      navigate({ to: "/auth/two-factor" })
      return
    }

    navigate({ to: redirectTo })
  }
})

Components

<TwoFactorChallenge />

Offers the methods the sign-in response reported, plus backup-code recovery and an optional "trust this device" checkbox. Emailed codes are sent on request rather than automatically, so a user with an authenticator app never triggers a pointless email.

Prop

Type

<TwoFactorSettings />

Added to <SecuritySettings /> automatically. Users can enroll with an authenticator app or a delivered code. Enrolled users can regenerate backup codes or turn two-factor off.

Prop

Type

Backup codes live in component state and are never written to storage or the query cache: once the dialog closes they are gone.

Delivered-code enrollment

The enrollment dialog offers authenticator apps by default. Configure OTP delivery on the server before you add the delivered-code option:

// Server
twoFactor({
  otpOptions: { sendOTP }
})
// UI
twoFactorPlugin({ enrollmentMethods: ["totp", "otp"] })

Better Auth activates OTP enrollment immediately. The dialog closes after authClient.twoFactor.enable({ method: "otp" }) succeeds.

Passwordless accounts

Set allowPasswordless on both sides to let passkey-only users manage two-factor without a password:

// Server
twoFactor({ allowPasswordless: true })
// UI
twoFactorPlugin({ allowPasswordless: true })

The UI still asks for a password when the account has a credential account, matching the server's rule. It reads the linked accounts to decide, so users who do have a password are not offered a shortcut around it.

Options

Prop

Type

Localization

Prop

Type

Read these from useAuthPlugin(twoFactorPlugin).localization inside custom slot components.

Email template

Pair otpOptions.sendOTP with the <OtpEmail /> component for a styled code email.

Last updated on

On this page