GenerateSaaS

Two-factor & passkeys

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

Account security beyond passwords, powered by three Better Auth plugins registered in @repo/auth (packages/auth/src/config.ts). These ship always enabled - there is no feature flag of their own; 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-based 2FA. config.sms powers transactional notifications, not login second factors. See SMS.

Two-factor authentication (TOTP)

The twoFactor plugin uses config.siteName as the authenticator issuer, giving each user an authenticator-app TOTP plus recovery backup codes.

Enrollment on /settings/security runs in order:

Confirm password. The user re-enters their password to authorize enabling 2FA.

Scan QR. A QR code renders for any authenticator app (Google Authenticator, 1Password, Authy), with the secret also shown as text for manual entry.

Verify TOTP. The user submits a current 6-digit code (twoFactor.verifyTotp) to activate 2FA.

Save backup codes. One-time backup codes (returned by twoFactor.enable) are displayed for recovery if the device is lost.

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

// packages/auth/src/config.ts - rateLimit.customRules
"/two-factor/verify-totp":        { window: 60, max: 5 }
"/two-factor/verify-otp":         { 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. From /settings/security (when 2FA is on), twoFactor.generateBackupCodes regenerates the set behind a password challenge and invalidates the old codes - existing codes are never re-displayed.

Passkeys (WebAuthn)

The passkey plugin (@better-auth/passkey) enables passwordless, phishing-resistant sign-in backed by WebAuthn - biometrics (Face ID, Touch ID, Windows Hello) or hardware security keys.

It derives rpID and origin from apiOrigin (computed as new URL(API_URL).origin in @repo/runtime), so passkeys bind to the API host:

passkey({
  rpID: new URL(apiOrigin).hostname,
  rpName: config.siteName,
  origin: apiOrigin
})

Set the API_URL env var correctly in production (web-next falls back to NEXT_PUBLIC_API_URL). If the derived rpID/origin don't match the host serving the page, passkey registration fails.

  • Users register multiple passkeys, name each, and revoke any device from security settings.
  • The private key stays on the device; only the public credential is stored server-side.

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 - reducing friction for returning users.

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 login 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 - they are independent plugins. A passkey login is passwordless, while a password sign-in still triggers the 2FA challenge when enabled.

Are passkeys synced across devices? That depends on the platform authenticator (iCloud Keychain, Google Password Manager). The app stores and manages each registered credential individually regardless.

On this page