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
| Plugin | Call | Method | Where it surfaces |
|---|---|---|---|
twoFactor | twoFactor({ issuer: config.siteName }) | TOTP + backup codes | /settings/security: enable/disable 2FA, scan QR, regenerate backup codes |
passkey | passkey({ rpID, rpName, origin }) | WebAuthn | /settings/security: register a passkey, name it, list & revoke devices |
lastLoginMethod | lastLoginMethod() | - | 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:
twoFactor.enable({ password }) returns a totpURI plus one-time backupCodes.totpURI renders as a QR code client-side (the secret is also shown for manual entry) for any authenticator app.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
})| Action | Client call |
|---|---|
| List credentials | authClient.passkey.listUserPasskeys() |
| Register | authClient.passkey.addPasskey({ name }) |
| Revoke | authClient.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.