Skip to Content
Delmare Digital

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 twoFactor plugin 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.

Edit this page on GitHub

On this page