payload-auth
Guides

Passkeys and two-factor

Add WebAuthn passkeys and TOTP two-factor authentication, including the admin panel views.

Both plugins are supported first-class: the plugin generates their collections and wires their admin UI.

Passkeys

Install and enable

npm install @better-auth/passkey
src/lib/auth/options.ts
import { passkey } from '@better-auth/passkey'

export const betterAuthOptions = {
  plugins: [
    passkey({
      rpID: 'localhost',
      rpName: 'My App',
      origin: 'http://localhost:3000',
    }),
  ],
} satisfies BetterAuthOptions

rpID is the registrable domain without protocol or port (localhost in development, example.com in production). origin is the full origin. Both must match what the browser sees or WebAuthn refuses to run.

Add the client plugin

src/lib/auth/client.ts
import { passkeyClient } from '@better-auth/passkey/client'

export const authClient = createAuthClient({
  plugins: [passkeyClient()],
})

Regenerate the import map

pnpm payload generate:importmap

The passkey button appears on the admin login view automatically, because setLoginMethods adds passkey when the plugin is detected.

This creates a passkeys collection with the credential ID, public key, counter and a user relationship.

Managing passkeys

A Passkeys server component is exported for building a management screen:

import { Passkeys } from 'payload-auth/better-auth/plugin/rsc'

Or drive it from the client:

await authClient.passkey.addPasskey({ name: 'MacBook' })
const { data } = await authClient.passkey.listUserPasskeys()
await authClient.passkey.deletePasskey({ id })

Two-factor authentication

Enable the plugin

src/lib/auth/options.ts
import { twoFactor } from 'better-auth/plugins'

export const betterAuthOptions = {
  plugins: [
    twoFactor({
      issuer: 'My App',
      otpOptions: {
        async sendOTP({ user, otp }) {
          await sendEmail({ to: user.email, subject: 'Your code', text: otp })
        },
      },
    }),
  ],
} satisfies BetterAuthOptions

issuer is the label shown in authenticator apps.

Add the client plugin

src/lib/auth/client.ts
import { twoFactorClient } from 'better-auth/client/plugins'

export const authClient = createAuthClient({
  plugins: [
    twoFactorClient({
      onTwoFactorRedirect() {
        window.location.href = '/two-factor'
      },
    }),
  ],
})

Regenerate the import map

pnpm payload generate:importmap

This creates a twoFactors collection (secret, backup codes, user relationship), adds a twoFactorEnabled checkbox to users, and registers the /admin/two-factor-verify view.

The admin challenge

When an admin with 2FA enabled signs in, they are redirected to /admin/two-factor-verify, which accepts a TOTP code or a backup code. This view is only registered when the twoFactor plugin is present.

Enrolling

const { data } = await authClient.twoFactor.enable({ password })
// data.totpURI  → render as a QR code
// data.backupCodes → show once, tell the user to store them

await authClient.twoFactor.verifyTotp({ code })

A TwoFactorAuth client component that handles enrolment (QR code, verification, backup codes) is exported for reuse:

import { TwoFactorAuth } from 'payload-auth/better-auth/plugin/client'

Locking yourself out

If a user enables 2FA and loses both their authenticator and their backup codes, they cannot sign in. As an admin you can clear the situation by deleting their twoFactors record and unchecking twoFactorEnabled on their user document in the admin panel.

Other passwordless methods

magicLink, emailOTP, phoneNumber and anonymous are all supported. Enable the Better Auth plugin, add its client counterpart, and — if you want it on the admin login screen — list the matching method in admin.loginMethods (magicLink, emailOTP, phoneOTP, phoneMagicLink, phonePassword).

On this page