GenerateSaaS

Two-factor & passkeys

Add TOTP two-factor, WebAuthn passkeys, and a last-login hint via Better Auth plugins.

@repo/auth registers three account-security plugins on the Better Auth instance (packages/auth/src/config.ts). All three are always on (no @repo/config flag) and mirrored on the Nuxt client in apps/web-nuxt/app/utils/auth.ts.

What each plugin adds

PluginServer configSecond factorClient plugin
twoFactorissuer: config.siteNameTOTP + backup codes only (no SMS)twoFactorClient()
passkeyrpID/origin from apiOrigin, rpName: config.siteNameWebAuthn credentialpasskeyClient()
lastLoginMethodnonen/a (hint only)lastLoginMethodClient()

2FA is TOTP and backup codes only - there is no SMS second factor. config.sms powers transactional notifications, not authentication. To verify by SMS you would add it yourself.

Two-factor (TOTP)

All 2FA UI lives in apps/web-nuxt/app/pages/settings/security.vue, which opens these modals:

ModalBetter Auth callNotes
Enable2FAModal.vuetwoFactor.enable({ password })twoFactor.verifyTotp({ code })Re-auths with password, then enrolls
Disable2FAModal.vuetwoFactor.disable({ password })Password required to turn off
BackupCodesModal.vuetwoFactor.generateBackupCodes({ password })Regenerates and shows a fresh set

Enrollment in Enable2FAModal.vue:

User confirms their password; authClient.twoFactor.enable({ password }) returns a totpURI plus backupCodes.
totpURI is rendered client-side as a QR code (the secret is also shown for manual entry); the user scans it in an authenticator app (Google Authenticator, 1Password, …).
authClient.twoFactor.verifyTotp({ code }) finalizes enrollment and reveals the backup codes for the user to save.

At sign-in, the twoFactorClient's onTwoFactorRedirect hook sends users to the /auth/2fa route (built from config.routes.auth with the active locale prefix). That page (apps/web-nuxt/app/pages/auth/2fa.vue) accepts a TOTP code (with an optional trustDevice checkbox) or, via a toggle, a backup code through twoFactor.verifyBackupCode.

These endpoints are rate-limited to 5 attempts / 60s each (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 },

Passkeys (WebAuthn)

The passkey plugin binds credentials to the API host, not config.baseUrl:

passkey({
  rpID: new URL(apiOrigin).hostname, // WebAuthn relying-party ID
  rpName: config.siteName,
  origin: apiOrigin                  // must match the browser's origin
})

apiOrigin is derived from API_URL in @repo/runtime. Passkey management is wired from settings/security.vue:

ActionBetter Auth call
List credentialsauthClient.passkey.listUserPasskeys() (called in security.vue)
Add (AddPasskeyModal.vue)authClient.passkey.addPasskey({ name })
Remove (DeletePasskeyModal.vue)authClient.passkey.deletePasskey({ id })

Set API_URL correctly in production. If rpID/origin don't match the browser's actual origin, passkey registration and login fail with an opaque WebAuthn error.

Last-login hint

lastLoginMethod() records the previous sign-in method. On the Nuxt sign-in form, authClient.getLastUsedLoginMethod() surfaces a non-enforcing "last used" badge (auth.last_used) next to the relevant method. It is a convenience hint only - it never blocks or changes which methods are allowed.

Disabling a plugin

There is no feature flag. To remove one, delete both halves:

  • the server plugin in packages/auth/src/config.ts
  • its client counterpart in apps/web-nuxt/app/utils/auth.ts

DB cascade still cleans up twoFactors and passkeys rows on account deletion regardless.

Frequently asked questions

Can users sign in with an SMS code as the second factor? No. The twoFactor plugin is TOTP + backup codes only. config.sms is for notifications, not authentication.

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

Are 2FA and passkeys optional per user? Yes. Both are self-service opt-ins from /settings/security. The plugins are enabled platform-wide, but no user is forced to enroll.

Where are backup codes shown? Once after verifyTotp succeeds (in Enable2FAModal.vue), and again whenever a user regenerates them via BackupCodesModal.vue. Users should save them - they gate sign-in if the authenticator is lost.

On this page