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 for you and route to the challenge with redirectTo preserved, so the user lands where they were headed once the second factor clears.

Better Auth doesn't 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} />
}
app/auth/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Auth } from "@better-auth-ui/heroui"
import { twoFactorPlugin } from "@better-auth-ui/heroui/plugins"
import { notFound } from "next/navigation"

export default async function AuthPage({
  params
}: {
  params: Promise<{ path: string }>
}) {
  const { path } = await params

  if (
    !Object.values({
      ...viewPaths.auth,
      ...twoFactorPlugin().viewPaths?.auth 
    }).includes(path)
  ) {
    notFound()
  }

  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"

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. Enrollment asks for the password, shows the QR code and setup key, requires one verified code, then displays the backup codes once. 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're gone.

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 aren't 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
})

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