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
| Plugin | Server config | Second factor | Client plugin |
|---|---|---|---|
twoFactor | issuer: config.siteName | TOTP + backup codes only (no SMS) | twoFactorClient() |
passkey | rpID/origin from apiOrigin, rpName: config.siteName | WebAuthn credential | passkeyClient() |
lastLoginMethod | none | n/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:
| Modal | Better Auth call | Notes |
|---|---|---|
Enable2FAModal.vue | twoFactor.enable({ password }) → twoFactor.verifyTotp({ code }) | Re-auths with password, then enrolls |
Disable2FAModal.vue | twoFactor.disable({ password }) | Password required to turn off |
BackupCodesModal.vue | twoFactor.generateBackupCodes({ password }) | Regenerates and shows a fresh set |
Enrollment in Enable2FAModal.vue:
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:
| Action | Better Auth call |
|---|---|
| List credentials | authClient.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.