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
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