---
name: two-factor-authentication-best-practices
description: This skill provides guidance and enforcement rules for implementing secure two-factor authentication (2FA) using Better Auth's twoFactor plugin.
---
## Setting Up Two-Factor Authentication
When adding 2FA to your application, configure the `twoFactor` plugin with your app name as the issuer. This name appears in authenticator apps when users scan the QR code.
```ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
appName: "My App", // Used as the default issuer for TOTP
plugins: [
twoFactor({
issuer: "My App", // Optional: override the app name for 2FA specifically
}),
],
});
```
**Note**: After adding the plugin, run `npx @better-auth/cli migrate` to add the required database fields and tables.
### Client-Side Setup
Add the client plugin and configure the redirect behavior for 2FA verification:
```ts
import { createAuthClient } from "better-auth/client";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = "/2fa"; // Redirect to your 2FA verification page
},
}),
],
});
```
## Enabling 2FA for Users
When a user enables 2FA, require their password for verification. The enable endpoint returns a TOTP URI for QR code generation and backup codes for account recovery.
```ts
const enable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.enable({
password,
});
if (data) {
// data.totpURI - Use this to generate a QR code
// data.backupCodes - Display these to the user for safekeeping
}
};
```
**Important**: The `twoFactorEnabled` flag on the user is not set to `true` until the user successfully verifies their first TOTP code. This ensures users have properly configured their authenticator app before 2FA is fully active.
### Skipping Initial Verification
If you want to enable 2FA immediately without requiring verification, set `skipVerificationOnEnable`:
```ts
twoFactor({
skipVerificationOnEnable: true, // Not recommended for most use cases
});
```
**Note**: This is generally not recommended as it doesn't confirm the user has successfully set up their authenticator app.
## TOTP (Authenticator App)
TOTP generates time-based codes using an authenticator app (Google Authenticator, Authy, etc.). Codes are valid for 30 seconds by default.
### Displaying the QR Code
Use the TOTP URI to generate a QR code for users to scan:
```tsx
import QRCode from "react-qr-code";
const TotpSetup = ({ totpURI }: { totpURI: string }) => {
return ;
};
```
### Verifying TOTP Codes
Better Auth accepts codes from one period before and one after the current time, accommodating minor clock differences between devices:
```ts
const verifyTotp = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true, // Optional: remember this device for 30 days
});
};
```
### TOTP Configuration Options
```ts
twoFactor({
totpOptions: {
digits: 6, // 6 or 8 digits (default: 6)
period: 30, // Code validity period in seconds (default: 30)
},
});
```
## OTP (Email/SMS)
OTP sends a one-time code to the user's email or phone. You must implement the `sendOTP` function to deliver codes.
### Configuring OTP Delivery
```ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
import { sendEmail } from "./email";
export const auth = betterAuth({
plugins: [
twoFactor({
otpOptions: {
sendOTP: async ({ user, otp }, ctx) => {
await sendEmail({
to: user.email,
subject: "Your verification code",
text: `Your code is: ${otp}`,
});
},
period: 5, // Code validity in minutes (default: 3)
digits: 6, // Number of digits (default: 6)
allowedAttempts: 5, // Max verification attempts (default: 5)
},
}),
],
});
```
### Sending and Verifying OTP
```ts
// Request an OTP to be sent
const sendOtp = async () => {
const { data, error } = await authClient.twoFactor.sendOtp();
};
// Verify the OTP code
const verifyOtp = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyOtp({
code,
trustDevice: true,
});
};
```
### OTP Storage Security
Configure how OTP codes are stored in the database:
```ts
twoFactor({
otpOptions: {
storeOTP: "encrypted", // Options: "plain", "encrypted", "hashed"
},
});
```
For custom encryption:
```ts
twoFactor({
otpOptions: {
storeOTP: {
encrypt: async (token) => myEncrypt(token),
decrypt: async (token) => myDecrypt(token),
},
},
});
```
## Backup Codes
Backup codes provide account recovery when users lose access to their authenticator app or phone. They are generated automatically when 2FA is enabled.
### Displaying Backup Codes
Always show backup codes to users when they enable 2FA:
```tsx
const BackupCodes = ({ codes }: { codes: string[] }) => {
return (
Save these codes in a secure location:
{codes.map((code, i) => (
- {code}
))}
);
};
```
### Regenerating Backup Codes
When users need new codes, regenerate them (this invalidates all previous codes):
```ts
const regenerateBackupCodes = async (password: string) => {
const { data, error } = await authClient.twoFactor.generateBackupCodes({
password,
});
// data.backupCodes contains the new codes
};
```
### Using Backup Codes for Recovery
```ts
const verifyBackupCode = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code,
trustDevice: true,
});
};
```
**Note**: Each backup code can only be used once and is removed from the database after successful verification.
### Backup Code Configuration
```ts
twoFactor({
backupCodeOptions: {
amount: 10, // Number of codes to generate (default: 10)
length: 10, // Length of each code (default: 10)
storeBackupCodes: "encrypted", // Options: "plain", "encrypted"
},
});
```
## Handling 2FA During Sign-In
When a user with 2FA enabled signs in, the response includes `twoFactorRedirect: true`:
```ts
const signIn = async (email: string, password: string) => {
const { data, error } = await authClient.signIn.email(
{
email,
password,
},
{
onSuccess(context) {
if (context.data.twoFactorRedirect) {
// Redirect to 2FA verification page
window.location.href = "/2fa";
}
},
}
);
};
```
### Server-Side 2FA Detection
When using `auth.api.signInEmail` on the server, check for 2FA redirect:
```ts
const response = await auth.api.signInEmail({
body: {
email: "user@example.com",
password: "password",
},
});
if ("twoFactorRedirect" in response) {
// Handle 2FA verification
}
```
## Trusted Devices
Trusted devices allow users to skip 2FA verification on subsequent sign-ins for a configurable period.
### Enabling Trust on Verification
Pass `trustDevice: true` when verifying 2FA:
```ts
await authClient.twoFactor.verifyTotp({
code: "123456",
trustDevice: true,
});
```
### Configuring Trust Duration
```ts
twoFactor({
trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days in seconds (default)
});
```
**Note**: The trust period refreshes on each successful sign-in within the trust window.
## Security Considerations
### Session Management
During the 2FA flow:
1. User signs in with credentials
2. Session cookie is removed (not yet authenticated)
3. A temporary two-factor cookie is set (default: 10-minute expiration)
4. User verifies via TOTP, OTP, or backup code
5. Session cookie is created upon successful verification
Configure the two-factor cookie expiration:
```ts
twoFactor({
twoFactorCookieMaxAge: 600, // 10 minutes in seconds (default)
});
```
### Rate Limiting
Better Auth applies built-in rate limiting to all 2FA endpoints (3 requests per 10 seconds). For OTP verification, additional attempt limiting is applied:
```ts
twoFactor({
otpOptions: {
allowedAttempts: 5, // Max attempts per OTP code (default: 5)
},
});
```
### Encryption at Rest
- TOTP secrets are encrypted using symmetric encryption with your auth secret
- Backup codes are stored encrypted by default
- OTP codes can be configured for plain, encrypted, or hashed storage
### Constant-Time Comparison
Better Auth uses constant-time comparison for OTP verification to prevent timing attacks.
### Credential Account Requirement
Two-factor authentication can only be enabled for credential (email/password) accounts. For social accounts, it's assumed the provider already handles 2FA.
## Disabling 2FA
Allow users to disable 2FA with password confirmation:
```ts
const disable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.disable({
password,
});
};
```
**Note**: When 2FA is disabled, trusted device records are revoked.
## Complete Configuration Example
```ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
import { sendEmail } from "./email";
export const auth = betterAuth({
appName: "My App",
plugins: [
twoFactor({
// TOTP settings
issuer: "My App",
totpOptions: {
digits: 6,
period: 30,
},
// OTP settings
otpOptions: {
sendOTP: async ({ user, otp }) => {
await sendEmail({
to: user.email,
subject: "Your verification code",
text: `Your code is: ${otp}`,
});
},
period: 5,
allowedAttempts: 5,
storeOTP: "encrypted",
},
// Backup code settings
backupCodeOptions: {
amount: 10,
length: 10,
storeBackupCodes: "encrypted",
},
// Session settings
twoFactorCookieMaxAge: 600, // 10 minutes
trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days
}),
],
});
```