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-factorcovering 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:
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 migrateInstall the matching client plugin
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-factorThis drops the following into your codebase:
src/lib/auth/two-factor-plugin.ts: thetwoFactorPlugin()factorysrc/lib/auth/use-sign-in-continuation.ts: shared post-sign-in handlersrc/lib/auth/two-factor-methods.ts: local redirect metadata storage and validationsrc/lib/auth/use-two-factor-password.ts: decides whether to ask for a passwordsrc/components/auth/otp-field.tsx: the shared code inputsrc/components/auth/two-factor/two-factor-challenge.tsx: the challenge viewsrc/components/auth/two-factor/two-factor-settings.tsx: the settings cardsrc/components/auth/two-factor/*-dialog.tsx: enable, disable, and regenerate dialogssrc/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
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
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 sessionThe 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
backupCodes controls the recovery and management UI. It does not disable backup codes on the server. Enrollment always shows any codes returned by the server so users can save them.
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