Better Auth 2FA: TOTP, OTP, backup codes, and sign-in redirects (2026)
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 Auth | Server and client sections below, then web 2FA docs |
| Expo / React Native on the same API | Same server plugin; add Expo auth and mobile 2FA |
| Clerk today with MFA on Pro | Migrate from Clerk, then enable the plugin |
| Security policy across the stack | Next.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:
| Method | What it is | When to use it |
|---|---|---|
| TOTP | Time-based codes from an authenticator app | Default for SaaS; works offline on the phone |
| OTP | One-time codes you send by email or SMS | Users without an authenticator app; you own deliverability |
| Backup codes | Single-use recovery codes stored hashed | Lost phone; show once at enrollment |
| Trusted device | Cookie that skips 2FA for 30 days on that browser | Personal 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.
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 generateThe plugin adds:
twoFactorEnabledon theusertable- A
twoFactortable (name configurable viatwoFactorTable) 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).
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(",")}`)
},
}),
],
});| Approach | Pros | Cons |
|---|---|---|
twoFactorPage | Simple; works without custom sign-in code | Full page reload |
onTwoFactorRedirect | Fits App Router useRouter / Expo navigation | You own routing state |
signIn.email onSuccess | Local to one form | Easy 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.viewBackupCodeson 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:
- No session cookie until verification succeeds. Server
afterhooks that readctx.context.newSessionmust null-check during the challenge. - Forward cookies on server-side flows. If you use
auth.api.signInEmailthenauth.api.verifyTOTP, pass headers/cookies from the first response into the second. twoFactorMethodsdrives 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:
- Include
twoFactorClientin the samecreateAuthClientcall asexpoClient. - Handle
onTwoFactorRedirectwith your navigation library (Expo Routerrouter.push). - After verification, session cookies land in SecureStore via
@better-auth/expolike 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
| Factor | Strength | Operational cost |
|---|---|---|
| TOTP | Strong; phishing-resistant compared to SMS | Support load when users lose phones; backup codes required |
| Email/SMS OTP | Better than password alone | SMS SIM swap; email deliverability; you pay per message |
| Backup codes | Recovery only | Users lose them; regenerate flow must be password-gated |
| Passkeys | Best UX for passwordless | Device loss still needs another factor or account recovery |
| Trusted device | Convenience | Stolen 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.
- Web 2FA documentation
- Mobile 2FA documentation
- Compare kits if you are still shopping
- Best SaaS starter kit 2026 for the wider build-vs-buy frame
If you are greenfield, start from the templates above instead of stitching plugins for a month.
FAQ
Better Auth intentionally discards the pending session until the second factor passes. Check twoFactorRedirect (or use onTwoFactorRedirect) and route to your challenge page. Do not treat the user as logged in until verifyTotp, verifyOtp, or verifyBackupCode succeeds.
For method: "totp", you must call twoFactor.verifyTotp after twoFactor.enable. Enable only returns totpURI and backup codes; activation happens on verify. If you need to confirm status, read session.user.twoFactorEnabled after verify, not after enable alone.
Trusted device is stored per client environment (cookie), lasts 30 days, and refreshes when the user completes 2FA again. Clearing cookies, switching browsers, private mode, or a new phone will prompt for 2FA. That is expected, not a broken session.
They can enable 2FA only if allowPasswordless: true. By default, users need a password to enroll because 2FA protects password sign-in. Social sign-in still relies on the provider unless you add custom enforcement.
Generated at enable (and via generateBackupCodes). Each code works once. verifyBackupCode can set trustDevice like TOTP. Regenerating invalidates the previous set.
Yes. Same server plugin and twoFactorClient. Use onTwoFactorRedirect with Expo Router and keep sending authClient.getCookie() on API calls. See Better Auth Expo and mobile 2FA.
Ship TOTP plus backup codes first (no per-message cost, works offline). Add email OTP when you have deliverability and templates ready. SMS last unless your audience demands it.
The Better Auth 2FA plugin documents twoFactorRedirect, twoFactorMethods, client callbacks, and server auth.api.signInEmail behavior. TurboStarter's web and mobile docs describe the product UI built on those APIs.
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.
Best SaaS starter kit 2026: 10 picks (Next.js, TanStack, Expo)
Compare 10 SaaS starter kits for 2026 on stack, pricing, teams, mobile, and Cloudflare support, with a clear pick for each type of product you build.
Better Auth with Expo: production React Native setup (2026)
Better Auth Expo guide: @better-auth/expo, SecureStore sessions, trustedOrigins, deep-link OAuth, and getCookie for API calls on one Next.js backend.



