Create new

Two-Factor Authentication

TOTP-based 2FA with authenticator app support, backup codes, and trusted devices.

Two-factor authentication adds a second verification step after password sign-in. launch.now implements TOTP (Time-based One-Time Passwords) — the standard used by Google Authenticator, Authy, and 1Password.

The flow splits into two paths depending on whether the user has 2FA enabled:

  • Without 2FA — sign in with email + password → session created immediately
  • With 2FA — sign in with email + password → redirected to OTP verification → session created after code entry

Configuration

import { betterAuth } from "better-auth"
import { twoFactor } from "better-auth/plugins"

export const auth = betterAuth({
  // ...
  plugins: [
    twoFactor({
      issuer: "launch.now",       // shown in the authenticator app
      otpOptions: {
        period: 30,               // code valid for 30 seconds
        digits: 6,
      },
      backupCodes: {
        enabled: true,
        count: 10,                // number of backup codes generated
        length: 10,               // length of each code
      },
    }),
  ],
})

Database migration

The 2FA plugin adds columns to the user table. Run the migration after updating auth.ts:

npx better-auth migrate

This adds: twoFactorEnabled, twoFactorSecret, twoFactorBackupCodes.


File structure

src/components/features/auth/two-factor/
  enable-2fa-dialog.tsx      ← QR code + TOTP setup flow
  verify-otp-form.tsx        ← OTP input shown during sign-in
  backup-codes-dialog.tsx    ← display + download backup codes
  disable-2fa-dialog.tsx     ← confirm + disable 2FA
  trusted-device-badge.tsx   ← "trust this device" checkbox

Enabling 2FA (user settings)

The enable flow has three steps: password confirmation → QR code scan → TOTP verification.

Initiate setup

Call enable2fa with the user's current password. Better Auth generates a TOTP secret and returns a URI you can render as a QR code.

const { data, error } = await authClient.twoFactor.enable({
  password,   // current password — required to prevent unauthorized setup
})

// data.totpURI  → pass to a QR code library (e.g. qrcode.react)
// data.backupCodes → show once, let user download/copy

Display the QR code

Render the totpURI as a QR code. The user scans it with their authenticator app.

import QRCode from "react-qr-code"

<QRCode value={data.totpURI} size={200} />

Also show the manual entry key for users who can't scan:

// Extract the secret from the URI
const secret = new URL(data.totpURI).searchParams.get("secret")

Verify and activate

Ask the user to enter the 6-digit code their app is now showing. This confirms the setup worked before locking it in.

const { error } = await authClient.twoFactor.verifyTotp({
  code,   // 6-digit code from authenticator app
})

// On success — 2FA is now active for this account

Signing in with 2FA

When a user with 2FA enabled submits their email + password, Better Auth intercepts the session creation and calls the onTwoFactorRedirect callback you defined in auth-client.ts. The user is sent to /verify-otp.

// Standard TOTP code
const { error } = await authClient.twoFactor.verifyTotp({
  code,
  trustDevice: rememberDevice,   // skip 2FA on this device for 30 days
})

// Backup code (when user lost their authenticator)
const { error } = await authClient.twoFactor.verifyBackupCode({
  code,   // one of the 10-character backup codes
})

Backup codes are single-use. Once a code is used it's invalidated. When all backup codes are exhausted the user needs to generate a new set from their account settings.


Trusted devices

When trustDevice: true is passed during verification, Better Auth sets a long-lived cookie that skips the OTP step on that device for 30 days.

<label className="flex items-center gap-2 text-sm">
  <input
    type="checkbox"
    checked={rememberDevice}
    onChange={(e) => setRememberDevice(e.target.checked)}
  />
  Trust this device for 30 days
</label>

Backup codes

Backup codes are generated when 2FA is first enabled. Show them once, let the user copy or download them, and never display them again.

// Regenerate backup codes (invalidates the old ones)
const { data } = await authClient.twoFactor.generateBackupCodes({
  password,   // confirm identity before regenerating
})

// data.backupCodes → string[]

Always let users download backup codes as a .txt file. A user locked out of their authenticator app with no backup codes is locked out forever unless you have an account recovery flow.


Disabling 2FA

const { error } = await authClient.twoFactor.disable({
  password,   // required — prevents someone with a stolen session from disabling 2FA
})

Checking 2FA status

const { data: session } = useSession()

const has2FA = session?.user.twoFactorEnabled

Use this to conditionally show the enable/disable button in account settings.