BETTER-AUTH. UI
Plugins

Two Factor

Add a second step after the password to your Solid/Zaidan auth UI.

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 copied <TwoFactorChallenge /> view at /auth/two-factor covering authenticator codes, emailed codes, backup codes, and "trust this device"
  • A copied <TwoFactorSettings /> card in security settings for enrolling, showing the QR code, and managing backup codes
  • Solid runtime wiring through verifyTotpOptions and the other two-factor mutation factories

The copied sign-in form detects twoFactorRedirect for you and routes 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 twoFactor() to your Better Auth server config. Wire otpOptions.sendOTP if you want to offer emailed codes as a second factor:

src/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 Solid client plugin

src/lib/auth-client.ts
import { twoFactorClient } from "better-auth/client/plugins"
import { createAuthClient } from "@better-auth-ui/solid"

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.

Add the copied components

npx shadcn@latest add https://better-auth-ui.com/r/solid/two-factor.json

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/*.tsx: the challenge view, settings card, dialogs, and backup-code display

The registry also refreshes the copied sign-in form so password and username sign-in both detect the two-factor redirect.

Register the plugin

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

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

Allow the new view path

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

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

const validAuthPathSegments = new Set([
  ...Object.values(viewPaths.auth),
  ...Object.values(twoFactorPlugin().viewPaths.auth) 
])

export const Route = createFileRoute("/auth/$path")({
  beforeLoad({ params: { path } }) {
    if (!validAuthPathSegments.has(path)) {
      throw redirect({ to: "/" })
    }
  },
  component: AuthPage
})

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 signIn = createMutation(() => ({
  ...signInEmailOptions(auth.authClient),
  onSuccess: (data) => {
    if (isTwoFactorRedirect(data)) {
      storeTwoFactorMethods(data.twoFactorMethods)
      auth.navigate({ to: "/auth/two-factor" })
      return
    }

    auth.navigate({ to: auth.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.

Last updated on

On this page