Two-factor authentication
The admin login's inline second-factor step, the factors it offers, standalone setup and verify views, and handling 2FA in your own forms.
With Better Auth's twoFactor() plugin enabled, the admin login handles two-factor inline. When a user with 2FA enabled signs in, the form moves to a second-factor step.
Factors offered
The step offers the factors that user actually holds. Better Auth reports them on the sign-in response, so someone with no authenticator set up lands on the emailed code rather than a TOTP input they can't satisfy.
- Authenticator app (TOTP) when the user has one.
- "Use a backup code" whenever the
twoFactorplugin is active. It is the escape hatch for a lost authenticator. - "Email me a code" when the plugin is configured with
otpOptions.sendOTP. Resends have a 30-second cooldown.
Both extra options are detected on the server. admin.login.enableTwoFactorBackupCode and admin.login.enableTwoFactorEmailOtp are ceilings over that detection: they can turn an option off, and never offer a factor the user doesn't hold.
QR codes for authenticator setup are rendered in the browser as inline SVG. The TOTP secret is never sent to a third-party service.
Users manage their own two-factor settings from their account; see Security management.
Standalone components
import { TwoFactorSetupView, TwoFactorVerifyView } from '@delmaredigital/payload-better-auth/components/twoFactor'
<TwoFactorSetupView logo={<MyLogo />} afterSetupPath="/admin" />
<TwoFactorVerifyView logo={<MyLogo />} afterVerifyPath="/admin" />Both also accept title and a completion callback (onSetupComplete / onVerifyComplete).
As an admin view, prefer the server wrapper. It detects whether the account has a password and skips the confirmation step when it doesn't:
import { TwoFactorSetupViewWrapper } from '@delmaredigital/payload-better-auth/rsc'Custom login forms
Always check result.data?.twoFactorRedirect after signIn.email(). Without this, users with 2FA enabled appear to sign in but are not authenticated.
const result = await signIn.email({ email, password })
if (result.data?.twoFactorRedirect) {
// twoFactorMethods is the factors THIS user holds: ['totp'], ['otp'],
// both, or [] when only backup codes are left. Offer from that list —
// defaulting to TOTP strands anyone who never set up an authenticator.
setTwoFactorMethods(result.data.twoFactorMethods)
return
}
// Then verify with whichever factor they chose:
await twoFactor.verifyTotp({ code }) // authenticator app
await twoFactor.verifyBackupCode({ code }) // backup code
await twoFactor.sendOtp() // then verifyOtp({ code })twoFactor here comes from a client built with twoFactorClient(); see Client.

