Skip to content
Better Auth: Two-Factor Auth

Better Auth: Two-Factor Auth Skill

Adds real 2FA to a Better Auth app — TOTP, email/SMS OTP, backup codes, trusted devices — including the sign-in interrupt and the encryption-at-rest details.

GitHub Docs

Install

Install with the skills CLI

One command for Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf and dozens of other agents. Needs Node.js; the skill is fetched straight from better-auth/skills.

$ npx skills add better-auth/skills --skill two-factor-authentication-best-practices

Run it in a terminal. The CLI finds the agents installed on your machine, asks which ones to add the skill to, and whether to install it into the folder you ran it from or for all your projects.

Other ways to install

  1. Open the skill on GitHub and download the folder that holds its SKILL.md.
  2. Put it at ~/.claude/skills/two-factor-authentication-best-practices/, where Claude Code picks it up in every project.

No scripts or references next to it? Saving the SKILL.md alone as ~/.claude/skills/two-factor-authentication-best-practices/SKILL.md works too.

Using another agent? Its personal skills folder
Codex
~/.agents/skills/
Cursor
~/.cursor/skills/
GitHub Copilot
~/.copilot/skills/
Gemini CLI
~/.gemini/skills/
Windsurf
~/.codeium/windsurf/skills/
OpenCode
~/.config/opencode/skills/

About

  • TOTP enrolment with QR code, first-code verification and period/digit config
  • Email and SMS OTP delivery, with a section on storing those codes safely
  • Backup codes: one-time display, regeneration and sign-in recovery
  • Trusted devices to skip the second factor on a proven machine
  • The sign-in interrupt flow when a password succeeds but 2FA is outstanding
  • Rate limiting the verify endpoint and encryption at rest for secrets

Two-factor authentication has three parts that must all be right: enrolment, the sign-in interrupt, and recovery. Ship two of them and you have either an unusable account-lockout generator or security theatre. This official Better Auth skill covers all three.

Enrolment starts with the plugin pair — twoFactor({ issuer }) on the server, twoFactorClient() on the client — then a migration that adds the twoFactorSecret column. TOTP covers rendering the enrolment QR code, verifying the first code, and the configuration knobs around period and digits. The OTP path covers delivering codes by email or SMS, sending and verifying them, and, importantly, how those codes are stored: the skill has a dedicated section on OTP storage security rather than leaving them as plaintext rows.

Recovery is treated as a first-class flow, not an afterthought. Backup codes are displayed once, regenerated on demand, and accepted at sign-in when the authenticator is gone, with configuration for count and format. Trusted devices let a user skip the second factor on a machine they have already proved out.

The sign-in section documents the interrupt itself — what the client receives when a password succeeds but a second factor is outstanding, and how to drive the follow-up. The security section covers what happens to sessions around 2FA changes, rate limiting the verification endpoint so codes cannot be brute-forced, and encryption at rest for secrets and backup codes. It finishes with disabling 2FA safely and a complete configuration example.

Published by the Better Auth team in their official skills repository, in the portable Agent Skills format read by Claude Code, Codex, Cursor and others.

SKILL.md

The instructions your agent loads, as published by Better Auth.

SKILL.md
name
two-factor-authentication-best-practices
description
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 auth@latest migrate (built-in adapter) or generate + push for Drizzle/Prisma
  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
    }),
  ],
});
Install
Featured

Auth0's official agent skill: a router that detects your framework and intent, then loads the right Auth0 guidance for login, MFA, Organizations, tenant audits, debugging or provider migration.

Browse all skills →