GenerateSaaS

Two-factor & passkeys

Secure accounts with TOTP two-factor, backup codes, and WebAuthn passkeys via the always-on Better Auth plugins.

Three Better Auth plugins registered in @repo/auth (packages/auth/src/config.ts) add account security beyond passwords. All three ship always enabled - there is no feature flag; users opt in from /settings/security.

What each plugin adds

PluginCallMethodWhere it surfaces
twoFactortwoFactor({ issuer: config.siteName })TOTP + backup codes/settings/security: enable/disable 2FA, scan QR, regenerate backup codes
passkeypasskey({ rpID, rpName, origin })WebAuthn/settings/security: register a passkey, name it, list & revoke devices
lastLoginMethodlastLoginMethod()-Sign-in screen: "Last used" badge over the matching option

2FA here is TOTP + backup codes only - there is no SMS second factor. config.sms powers transactional notifications, not login challenges. See SMS.

Two-factor (TOTP)

Enrollment lives in apps/web/app/[locale]/(dashboard)/settings/security/page.tsx and runs in order:

Confirm password. twoFactor.enable({ password }) returns a totpURI plus one-time backupCodes.
Scan QR. totpURI renders as a QR code client-side (the secret is also shown for manual entry) for any authenticator app.
Verify TOTP. twoFactor.verifyTotp({ code }) activates 2FA and reveals the backup codes to save.

At sign-in, an enabled factor redirects to /auth/2fa, which accepts a TOTP (verifyTotp) or a backup code (verifyBackupCode). Each verification endpoint is capped at 5 attempts / 60 s:

// packages/auth/src/config.ts - rateLimit.customRules
"/two-factor/verify-otp":         { window: 60, max: 5 }
"/two-factor/verify-totp":        { window: 60, max: 5 }
"/two-factor/verify-backup-code": { window: 60, max: 5 }
  • QR codes render client-side - the TOTP secret never round-trips an external service.
  • Backup codes are single-use. twoFactor.generateBackupCodes({ password }) regenerates the set and invalidates the old codes; existing codes are never re-displayed.

Passkeys (WebAuthn)

The passkey plugin enables passwordless, phishing-resistant sign-in via biometrics (Face ID, Touch ID, Windows Hello) or hardware keys, binding credentials to the API host:

passkey({
  rpID: new URL(apiOrigin).hostname,
  rpName: config.siteName,
  origin: apiOrigin
})
ActionClient call
List credentialsauthClient.passkey.listUserPasskeys()
RegisterauthClient.passkey.addPasskey({ name })
RevokeauthClient.passkey.deletePasskey({ id })

apiOrigin is new URL(API_URL).origin from @repo/runtime (web falls back to NEXT_PUBLIC_API_URL). Set API_URL correctly in production - a mismatch between the derived rpID/origin and the browser's origin fails registration with an opaque WebAuthn error.

Last-login method hint

lastLoginMethod() records how a user last authenticated (password, a social provider, or passkey) and renders a "last used" badge over the matching option on the sign-in screen. It is a hint only - it never restricts which methods are allowed.

Removing a plugin

There is no feature flag. Delete both halves: the server plugin in packages/auth/src/config.ts and its client counterpart in apps/web/lib/auth-client.ts. Account deletion still cascades the twoFactors and passkeys rows either way.

Frequently asked questions

Does this support SMS two-factor authentication? No. The second factor is TOTP or a backup code only. config.sms handles transactional notifications, not 2FA challenges.

What if a user loses both their authenticator and backup codes? There is no plaintext recovery of the TOTP secret. An admin must reset the user's two-factor settings to restore access.

Can a user have both 2FA and passkeys enabled? Yes - independent plugins. A passkey login is passwordless, while a password sign-in still triggers the 2FA challenge when enabled.

Why does passkey registration work locally but fail in production? Almost always an apiOrigin mismatch. WebAuthn requires rpID/origin to match the browser's origin exactly - check the deployed API_URL.

On this page