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-factorcovering 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
verifyTotpOptionsand 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:
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 Solid client plugin
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.jsonThis 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/*.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
import { twoFactorPlugin } from "@/lib/auth/two-factor-plugin"
<AuthProvider
authClient={authClient}
navigate={navigate}
plugins={[twoFactorPlugin()]}
>
{props.children}
</AuthProvider>Allow the new view path
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 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 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