Files
portal/.agents/skills/two-factor-authentication-best-practices/SKILL.md
T
NeilClack 38d1752f5b admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix
- Add Superforms + Zod 4 for all admin form validation
- Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping)
- Update profiles page with three-checkbox address form and toast improvements
- Convert all admin page server actions to pass request directly to crud
- Export superFormErrorsToFieldErrors helper for profiles server actions
- Regenerate Prisma client for new schema fields
2026-07-07 13:36:23 -05:00

7.1 KiB

name, description
name description
two-factor-authentication-best-practices Configure TOTP authenticator apps, send OTP codes via email/SMS, manage backup codes, handle trusted devices, and implement 2FA sign-in flows using Better Auth's twoFactor plugin. Use when users need MFA, multi-factor authentication, authenticator setup, or login security with Better Auth.

Setup

  1. Add twoFactor() plugin to server config with issuer
  2. Add twoFactorClient() plugin to client config
  3. Run npx @better-auth/cli migrate
  4. Verify: check that twoFactorSecret column exists on user table
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';

export const auth = betterAuth({
	appName: 'My App',
	plugins: [
		twoFactor({
			issuer: 'My App'
		})
	]
});

Client-Side Setup

import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
	plugins: [
		twoFactorClient({
			onTwoFactorRedirect() {
				window.location.href = '/2fa';
			}
		})
	]
});

Enabling 2FA for Users

Requires password verification. Returns TOTP URI (for QR code) and backup codes.

const enable2FA = async (password: string) => {
	const { data, error } = await authClient.twoFactor.enable({
		password
	});

	if (data) {
		// data.totpURI — generate a QR code from this
		// data.backupCodes — display to user
	}
};

twoFactorEnabled is not set to true until first TOTP verification succeeds. Override with skipVerificationOnEnable: true (not recommended).

TOTP (Authenticator App)

Displaying the QR Code

import QRCode from 'react-qr-code';

const TotpSetup = ({ totpURI }: { totpURI: string }) => {
	return <QRCode value={totpURI} />;
};

Verifying TOTP Codes

Accepts codes from one period before/after current time:

const verifyTotp = async (code: string) => {
	const { data, error } = await authClient.twoFactor.verifyTotp({
		code,
		trustDevice: true
	});
};

TOTP Configuration Options

twoFactor({
	totpOptions: {
		digits: 6, // 6 or 8 digits (default: 6)
		period: 30 // Code validity period in seconds (default: 30)
	}
});

OTP (Email/SMS)

Configuring OTP Delivery

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

Send: authClient.twoFactor.sendOtp(). Verify: authClient.twoFactor.verifyOtp({ code, trustDevice: true }).

OTP Storage Security

Configure how OTP codes are stored in the database:

twoFactor({
	otpOptions: {
		storeOTP: 'encrypted' // Options: "plain", "encrypted", "hashed"
	}
});

For custom encryption:

twoFactor({
	otpOptions: {
		storeOTP: {
			encrypt: async (token) => myEncrypt(token),
			decrypt: async (token) => myDecrypt(token)
		}
	}
});

Backup Codes

Generated automatically when 2FA is enabled. Each code is single-use.

Displaying Backup Codes

const BackupCodes = ({ codes }: { codes: string[] }) => {
	return (
		<div>
			<p>Save these codes in a secure location:</p>
			<ul>
				{codes.map((code, i) => (
					<li key={i}>{code}</li>
				))}
			</ul>
		</div>
	);
};

Regenerating Backup Codes

Invalidates all previous codes:

const regenerateBackupCodes = async (password: string) => {
	const { data, error } = await authClient.twoFactor.generateBackupCodes({
		password
	});
	// data.backupCodes contains the new codes
};

Using Backup Codes for Recovery

const verifyBackupCode = async (code: string) => {
	const { data, error } = await authClient.twoFactor.verifyBackupCode({
		code,
		trustDevice: true
	});
};

Backup Code Configuration

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

Response includes twoFactorRedirect: true when 2FA is required:

Sign-In Flow

  1. Call signIn.email({ email, password })
  2. Check context.data.twoFactorRedirect in onSuccess
  3. If true, redirect to /2fa verification page
  4. Verify via TOTP, OTP, or backup code
  5. Session cookie is created on successful verification
const signIn = async (email: string, password: string) => {
	const { data, error } = await authClient.signIn.email(
		{ email, password },
		{
			onSuccess(context) {
				if (context.data.twoFactorRedirect) {
					window.location.href = '/2fa';
				}
			}
		}
	);
};

Server-side: check "twoFactorRedirect" in response when using auth.api.signInEmail.

Trusted Devices

Pass trustDevice: true when verifying. Default trust duration: 30 days (trustDeviceMaxAge). Refreshes on each sign-in.

Security Considerations

Session Management

Flow: credentials → session removed → temporary 2FA cookie (10 min default) → verify → session created.

twoFactor({
	twoFactorCookieMaxAge: 600 // 10 minutes in seconds (default)
});

Rate Limiting

Built-in: 3 requests per 10 seconds for all 2FA endpoints. OTP has additional attempt limiting:

twoFactor({
	otpOptions: {
		allowedAttempts: 5 // Max attempts per OTP code (default: 5)
	}
});

Encryption at Rest

TOTP secrets: encrypted with auth secret. Backup codes: encrypted by default. OTP: configurable ("plain", "encrypted", "hashed"). Uses constant-time comparison for verification.

2FA can only be enabled for credential (email/password) accounts.

Disabling 2FA

Requires password confirmation. Revokes trusted device records:

const disable2FA = async (password: string) => {
	const { data, error } = await authClient.twoFactor.disable({
		password
	});
};

Complete Configuration Example

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
		})
	]
});