10+ AI SaaS templates for web & mobile
home

Better Auth 2FA: TOTP, OTP, backup codes, and sign-in redirects (2026)

·13 min read

Better Auth 2FA guide for Next.js and Expo: twoFactor plugin setup, TOTP enrollment, email/SMS OTP, backup codes, trusted devices, and twoFactorRedirect on sign-in.

If you searched better auth 2fa, you are past picking an auth library. You already run Better Auth on Postgres and need two-factor authentication that works on web and mobile without a per-seat MFA bill. The docs are solid, but the sharp edges show up in production: twoFactorRedirect on sign-in, verifying TOTP after enable, trusted devices that feel like they "only work once", and OTP senders you still have to wire yourself.

This guide is the end-to-end path TurboStarter ships in Core Kit: TOTP (authenticator apps), optional email or SMS OTP, backup codes, trusted devices, and the two factor redirect flow on Next.js and Expo. For vendor comparison, read Better Auth vs Clerk vs NextAuth vs Supabase Auth. For auth screens, see Better Auth UI. For mobile sessions, see Better Auth with Expo.

Short answer

Better Auth 2FA is the twoFactor plugin on the server plus twoFactorClient in the browser or Expo app. Add twoFactor() to plugins, run npx auth migrate (or npx auth generate for Drizzle/Prisma), then call authClient.twoFactor.enable({ password, method: "totp" }) to get a totpURI and backup codes. TOTP stays off until the user verifies a code unless you set skipVerificationOnEnable: true. On password sign-in, the response includes twoFactorRedirect: true and twoFactorMethods; handle it with onTwoFactorRedirect, twoFactorPage, or an onSuccess check, then call verifyTotp, verifyOtp, or verifyBackupCode. Pass trustDevice: true to skip the second step for 30 days on that browser (refreshed on each successful sign-in). TurboStarter wires enrollment, challenge UI, and recovery on web and mobile from one backend.

Who this guide is for

You are building…Start here
Next.js App Router + Better AuthServer and client sections below, then web 2FA docs
Expo / React Native on the same APISame server plugin; add Expo auth and mobile 2FA
Clerk today with MFA on ProMigrate from Clerk, then enable the plugin
Security policy across the stackNext.js security guide for rate limits and CSRF

You do not need a second user database for mobile. One twoFactor plugin, one two_factor table, same twoFactorEnabled flag on user.

What Better Auth two-factor authentication includes

Better Auth treats 2FA as a plugin, not a hosted add-on. The official 2FA plugin covers:

MethodWhat it isWhen to use it
TOTPTime-based codes from an authenticator appDefault for SaaS; works offline on the phone
OTPOne-time codes you send by email or SMSUsers without an authenticator app; you own deliverability
Backup codesSingle-use recovery codes stored hashedLost phone; show once at enrollment
Trusted deviceCookie that skips 2FA for 30 days on that browserPersonal laptops; not a substitute for passkeys

Passkeys are separate. They use the @better-auth/passkey plugin. Many teams run TOTP for password sign-in and passkeys for passwordless users. This post focuses on the twoFactor plugin; the comparison post covers passkeys in one table.

What 2FA does not gate by default

Credential sign-in (/sign-in/email, /sign-in/username, /sign-in/phone-number) is challenged when twoFactorEnabled is true. OAuth, magic links, email OTP sign-in, passkeys, and anonymous auth are not gated by 2FA unless you add custom hooks. If your product must require a second factor after Google sign-in, you need an explicit policy (for example redirect OAuth users into enrollment, or block sensitive routes until user.twoFactorEnabled).

That default is a common surprise. It is also why "I enabled 2FA but social login skips it" is expected behavior, not a bug.

Server setup: twoFactor plugin and schema

Install is already part of better-auth. Import the plugin and register it next to your other plugins.

packages/auth/src/server.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  appName: "My SaaS", // TOTP issuer label in authenticator apps
  plugins: [
    twoFactor({
      issuer: "My SaaS", // optional; defaults to appName
      // otpOptions: { async sendOTP({ user, otp }) { ... } },
      // allowPasswordless: true, // OAuth-only users can enable 2FA without a password
      // skipVerificationOnEnable: false, // keep false in production
    }),
  ],
  // database, emailAndPassword, socialProviders, ...
});

Run migrations after you add the plugin:

npx auth migrate
# or for schema codegen:
npx auth generate

The plugin adds:

  • twoFactorEnabled on the user table
  • A twoFactor table (name configurable via twoFactorTable) for secrets and backup material

TurboStarter's Drizzle schema in the kit includes these fields; see auth overview for the full plugin list (organizations, passkeys, Expo, and others).

Email and SMS OTP (otpOptions.sendOTP)

Better auth totp is the default enrollment path. OTP as a second factor needs a server sender:

twoFactor({
  otpOptions: {
    async sendOTP({ user, otp }) {
      await sendEmail({
        to: user.email,
        subject: "Your sign-in code",
        text: `Your code is ${otp}`,
      });
    },
  },
}),

Call authClient.twoFactor.sendOtp() during the sign-in challenge when twoFactorMethods includes "otp". You own rate limits, templates, and SMS cost. TurboStarter's Core Kit UI focuses on TOTP and backup codes; OTP is a small server config plus a button on your challenge screen (Edge Kit notes the same pattern).

Rate limiting and lockout

Better Auth rate-limits sensitive paths (including /two-factor/*) in production. Failed TOTP, OTP, and backup attempts share one account lockout counter per user; a successful verification resets it. Locked accounts return 429 with ACCOUNT_TEMPORARILY_LOCKED. Plan for this in your challenge UI (clear copy, backoff). The security guide covers proxy headers and shared rate-limit storage on serverless.

Client setup: twoFactorClient and redirects

Add the client plugin wherever you create authClient (Next.js and Expo).

packages/auth/src/client.ts
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_URL,
  plugins: [
    twoFactorClient({
      // Option A: full-page redirect (reloads the app)
      twoFactorPage: "/auth/two-factor",

      // Option B: SPA-friendly handler (pick one pattern)
      onTwoFactorRedirect({ twoFactorMethods }) {
        // e.g. router.push(`/auth/two-factor?methods=${twoFactorMethods.join(",")}`)
      },
    }),
  ],
});
ApproachProsCons
twoFactorPageSimple; works without custom sign-in codeFull page reload
onTwoFactorRedirectFits App Router useRouter / Expo navigationYou own routing state
signIn.email onSuccessLocal to one formEasy to miss on other entry points

For Next.js 2FA, register a dedicated route (for example /auth/two-factor) that reads twoFactorMethods and renders TOTP, OTP, or backup tabs. For React Native 2FA, navigate to the same challenge screen from onTwoFactorRedirect; the API and cookies are identical to web when you use @better-auth/expo.

Enabling 2FA: QR code, verify, then backup codes

Enrollment is a two-step flow for TOTP. That is the issue behind many "I called enable but 2FA is still off" reports.

Step 1: twoFactor.enable

const { data, error } = await authClient.twoFactor.enable({
  password: currentPassword,
  method: "totp", // default
  issuer: "My SaaS",
});

if (data?.method === "totp") {
  // data.totpURI → render QR locally (do not send to third-party QR APIs)
  // data.backupCodes → show once; user should store them
}

Until the user verifies a code, twoFactorEnabled stays false (unless you set skipVerificationOnEnable: true, which skips the enrollment check and is risky for production).

If the user already has a verified authenticator, enable returns TOTP_ALREADY_ENABLED. Disable or rotate before re-enrolling.

Passwordless accounts: OAuth, magic link, and passkey-only users cannot enable 2FA unless allowPasswordless: true. They still need a password to disable 2FA when a credential account exists. TurboStarter follows the same rules in web and mobile settings.

Step 2: twoFactor.verifyTotp (activation)

const { data, error } = await authClient.twoFactor.verifyTotp({
  code: sixDigitCode,
  // trustDevice is for sign-in; optional here
});

After success, twoFactorEnabled becomes true. Your settings UI should not show "2FA on" until this call succeeds.

Render the QR code in the browser from totpURI (for example react-qr-code). Better Auth documents treating the URI as a secret.

Regenerating URI or backup codes

  • authClient.twoFactor.getTotpUri({ password }) if you need to show the QR again (password-gated).
  • authClient.twoFactor.generateBackupCodes({ password }) replaces the old set.
  • auth.api.viewBackupCodes on the server only for fresh sessions (right after enable).

Sign-in: twoFactorRedirect and the challenge screen

When a user with 2FA signs in with email and password, Better Auth does not create a session yet. The sign-in response looks like:

const { data } = await authClient.signIn.email(
  { email, password },
  {
    async onSuccess(ctx) {
      if (ctx.data.twoFactorRedirect) {
        const methods = ctx.data.twoFactorMethods; // e.g. ["totp"] or ["totp", "otp"]
        // navigate to challenge UI
        return;
      }
      // normal redirect to dashboard
    },
  },
);

Important details for better auth two factor redirect debugging:

  1. No session cookie until verification succeeds. Server after hooks that read ctx.context.newSession must null-check during the challenge.
  2. Forward cookies on server-side flows. If you use auth.api.signInEmail then auth.api.verifyTOTP, pass headers/cookies from the first response into the second.
  3. twoFactorMethods drives your UI. Show TOTP input, "send email code", and backup code entry based on that array.

Verify the second factor

// TOTP (authenticator app)
await authClient.twoFactor.verifyTotp({
  code,
  trustDevice: rememberThisDevice,
});

// Email/SMS OTP (after sendOtp)
await authClient.twoFactor.sendOtp({ trustDevice: rememberThisDevice });
await authClient.twoFactor.verifyOtp({ code, trustDevice: rememberThisDevice });

// Backup code
await authClient.twoFactor.verifyBackupCode({
  code: backupCode,
  trustDevice: rememberThisDevice,
});

On success, Better Auth sets the normal session cookie. Your challenge route should send the user to the app home or deep link back into Expo.

Trusted devices (trustDevice)

Pass trustDevice: true on verifyTotp, verifyOtp, or verifyBackupCode. Better Auth remembers the device for 30 days and refreshes the trust period on each successful password sign-in from that device (plugin docs).

Practical notes that match GitHub discussions about "trust only works once":

  • Trust is per browser profile, not per user account globally. New browser, incognito, or cleared cookies → 2FA again.
  • Trust applies to the password + 2FA path. It does not automatically trust new devices for OAuth.
  • Mobile WebViews and Expo may use separate cookie jars from system Safari or Chrome; trust on web does not imply trust in the native app until the user checks the box there too.

TurboStarter exposes a "trust this device" checkbox on the sign-in challenge (web flow, mobile flow).

Disabling 2FA and account recovery

await authClient.twoFactor.disable({ password });

Requires the account password when a credential exists. Pair disable with re-auth for sensitive settings (password confirm before showing backup codes).

Educate users to store backup codes offline. Each backup code works once; used codes are removed server-side.

Expo and React Native notes

The server plugin is identical to web. On the client:

  1. Include twoFactorClient in the same createAuthClient call as expoClient.
  2. Handle onTwoFactorRedirect with your navigation library (Expo Router router.push).
  3. After verification, session cookies land in SecureStore via @better-auth/expo like any other sign-in.

Authenticator apps live outside your app. Deep-link back to your sign-in challenge route after the user copies a code. TurboStarter's mobile 2FA docs show the native enrollment and prompt screens.

For a kit with web + mobile already wired, see the Expo Better Auth template and Next.js Better Auth template.

Security tradeoffs: TOTP vs OTP vs passkeys

FactorStrengthOperational cost
TOTPStrong; phishing-resistant compared to SMSSupport load when users lose phones; backup codes required
Email/SMS OTPBetter than password aloneSMS SIM swap; email deliverability; you pay per message
Backup codesRecovery onlyUsers lose them; regenerate flow must be password-gated
PasskeysBest UX for passwordlessDevice loss still needs another factor or account recovery
Trusted deviceConvenienceStolen laptop within trust window bypasses 2FA

How to implement 2fa for most B2B SaaS on Better Auth: default to TOTP + backup codes, optional email OTP for recovery, short-lived challenge UI, and no skipVerificationOnEnable in production. Add passkeys for users who want passwordless. Require twoFactorEnabled for admin routes on the server (access control).

TurboStarter: production-ready 2FA across platforms

TurboStarter is a production-ready SaaS starter kit: Next.js, Expo, extension, and AI surfaces on one Better Auth backend and one Postgres schema. You own the auth tables, MFA is not metered per user, and the enrollment plus sign-in challenge screens are already built.

If you are greenfield, start from the templates above instead of stitching plugins for a month.

FAQ

Sounds good?Now let's make it real. In minutes.
Try TurboStarter

Better auth 2fa is not a separate product. It is the twoFactor plugin, a verified TOTP step after enable, a challenge route wired to twoFactorRedirect, and backup codes you treat like production secrets. Wire those pieces once on your Next.js API and Expo inherits the same rules. If you want that flow shipped in a monorepo you can own, TurboStarter bundles web, mobile, and extension auth on one Better Auth backend so you can ship MFA without paying per user or relearning redirect handling from GitHub threads.

world map
Community

Connect with like-minded people

Join our community to get feedback, support, and grow together with 1,000+ builders on board, let's ship it!

Join us

Ship your startup everywhere. In minutes.

Don't burn tokens on setup and start building features on day one.

Get TurboStarter