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 built-in sign-in forms detect twoFactorRedirect and open the challenge. They preserve redirectTo and use it after successful verification.

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. Generate the schema and run the migration:

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 UI handles the redirect itself and keeps redirectTo intact, while twoFactorPage forces a full page reload.

Register the UI plugin

components/providers.tsx
import { AuthProvider } from "@better-auth-ui/heroui"
import { twoFactorPlugin } from "@better-auth-ui/heroui/plugins"

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

Allow the new view path

The plugin contributes a two-factor segment to viewPaths.auth. Spread twoFactorPlugin().viewPaths?.auth into your auth route's allowed-paths set:

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

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 "@better-auth-ui/core/plugins/two-factor"

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

    navigate({ to: redirectTo })
  }
})

Components

<TwoFactorChallenge />

Rendered at /auth/two-factor. 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.

import { TwoFactorChallenge } from "@better-auth-ui/heroui/plugins"

<TwoFactorChallenge />

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.

import { TwoFactorSettings } from "@better-auth-ui/heroui/plugins"

<TwoFactorSettings />

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

twoFactorPlugin({
  // Override the URL segment. Default: "two-factor"
  path: "2fa",
  // Match the server's TOTP/OTP digits. Default: 6
  codeLength: 6,
  // Turn off when the server sets `backupCodeOptions: { enabled: false }`
  backupCodes: true,
  // Hide the "Trust this device" checkbox
  trustDevice: false,
  // Offer delivered-code enrollment after the server configures sendOTP
  enrollmentMethods: ["totp", "otp"]
})

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