Better Auth 2FA with TOTP: enroll, verify, and sign in with a code

Add TOTP two-factor auth to Better Auth: the twoFactor plugin, the enroll then verify flow, the sign-in challenge, and the gaps magic links leave open.

MC
Martin Coll

To add TOTP two-factor auth to Better Auth, register the twoFactor() plugin on the server, run the schema migration, then call enable (which returns a totpURI to render as a QR code and a set of backup codes) and verifyTotp with the first code from the authenticator app. From then on, password sign-in answers twoFactorRedirect: true instead of a session until the user submits a valid code. Two details decide whether this is actually secure: 2FA stays off until the first code is verified, and magic links and social sign-in do not go through it. This guide is checked against [email protected], the version useDeploy ships, and against its source.

Server and client setup

The two-factor plugin docs give the minimal setup:

import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins/two-factor';

export const auth = betterAuth({
  appName: 'My App',
  plugins: [twoFactor()],
});
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [twoFactorClient()],
});

The plugin adds a twoFactorEnabled flag to the user and a twoFactor table (secret, backupCodes, verified). Generate the migration with Better Auth's CLI, or copy the models into your Prisma schema by hand, as useDeploy does (model TwoFactor in apps/server/prisma/schema.prisma).

The enrollment flow

Enrollment is two calls, and the second one is the one people forget.

// 1. Start enrollment (needs a session; asks for the password on credential accounts)
const { data } = await authClient.twoFactor.enable({ password });
// data = { totpURI, backupCodes }

// 2. The user scans the QR code, then proves it works
await authClient.twoFactor.verifyTotp({ code: '012345' });

What the 1.6.9 source does (plugins/two-factor/index.mjs, plugins/two-factor/totp/index.mjs):

  • enable generates a random 32-character secret, encrypts it with BETTER_AUTH_SECRET and stores it with verified: false. It also generates backup codes, stored encrypted by default (storeBackupCodes: 'encrypted').
  • twoFactorEnabled stays false until verifyTotp succeeds. A user who scans the QR and walks away is not locked into 2FA.
  • A successful first verifyTotp flips twoFactorEnabled to true, marks the row verified and swaps the session for a fresh one.
  • The option skipVerificationOnEnable: true activates 2FA immediately. Leave it false: with it on, a typo in the authenticator setup locks the user out.

The totpURI contains the shared secret, so render the QR code locally. Do not send the URI to a third-party QR service.

Defaults worth knowing: 6 digits, 30 second period, and the verifier accepts the code from one period before and after the current one. issuer is the name shown in the authenticator app and defaults to your appName.

The sign-in challenge

When a user with 2FA enabled signs in with email and password, Better Auth does not create a session. In the source it deletes the session it just made, sets a signed two_factor cookie that lives 10 minutes by default (twoFactorCookieMaxAge), and answers:

{ "twoFactorRedirect": true, "twoFactorMethods": ["totp"] }

The client then collects a code and calls twoFactor.verifyTotp. The challenge cookie is what proves the first factor passed, so the second call carries no password. Passing trustDevice: true skips the challenge on that device for 30 days (trustDeviceMaxAge, 2592000 seconds).

On the client you can handle the redirect with onTwoFactorRedirect in twoFactorClient({ ... }), which avoids a full page reload compared with twoFactorPage.

What 2FA does not cover

This is the part the docs only mention in one line. In 1.6.9 the plugin intercepts /sign-in/email, /sign-in/username and /sign-in/phone-number. Other ways of minting a session are not challenged:

  • magic link verification (/magic-link/verify)
  • social sign-in and the OAuth callback
  • passkeys (these are a second factor on their own, so that one is usually fine)

If a user has TOTP enabled and also has a magic link flow, an attacker who reaches their inbox gets in without a code. Close the hole with an after hook that refuses single-factor sessions for those users.

How useDeploy handles it

The plugin setup is in apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts:

twoFactor({
  issuer: cfg.totpIssuer ?? 'Boilerplate',
  skipVerificationOnEnable: false,
}),

And the hook that closes the magic link and social sign-in bypass, in the same file:

const SINGLE_FACTOR_SESSION_PATHS = new Set([
  '/magic-link/verify',
  '/sign-in/social',
  '/callback/:id',
]);

hooks: {
  after: createAuthMiddleware(async (ctx) => {
    if (!SINGLE_FACTOR_SESSION_PATHS.has(ctx.path)) return;
    const created = ctx.context.newSession;
    if (!created?.user.twoFactorEnabled) return;
    deleteSessionCookie(ctx, true);
    await ctx.context.internalAdapter.deleteSession(created.session.token);
    throw ctx.redirect(`${cfg.origin}/login?error=two-factor-required`);
  }),
},

For a 2FA user, a magic link or Google sign-in creates a session, the hook deletes it, and the browser lands on /login?error=two-factor-required. Those users sign in with password plus TOTP, or a passkey. The ?. matters: if no session was created, newSession is unset.

On top of the plugin, useDeploy wraps the flow in its own Express routes (apps/server/src/modules/iam/interfaces/http/auth.routes.ts):

RouteAuthWhat it does
POST /api/v1/auth/2fa/enrollsessionenableTwoFactor, returns the URI, a pre-rendered QR data URL and backup codes
POST /api/v1/auth/2fa/verifysessionverifyTOTP, activates 2FA with the first code
POST /api/v1/auth/2fa/disablesessiondisableTwoFactor, requires the password
POST /api/v1/auth/2fa/verify-sign-inchallenge cookiesecond step of sign-in, accepts { code, trustDevice? }

Enroll, verify and disable are refused for API keys and while a platform admin is impersonating the user. The code is validated as exactly six digits by the shared Zod schema in @app/contracts.

Things to know before you ship it

  • Brute force on the code. Better Auth rate-limits /two-factor/* to 3 requests per 10 seconds, but only for HTTP requests to its own handler and only in production by default. useDeploy calls the plugin through auth.api, which skips that limiter, so verify-sign-in sits behind the app's own per-IP limiters instead. The same check is reachable natively at /api/auth/two-factor/verify-totp, limited to about 18 requests a minute per IP. Every path is keyed by IP; none is per account. See Better Auth rate limiting.
  • Backup codes. They are generated and returned at enrollment, and the plugin has a verify-backup-code endpoint. useDeploy's own routes and UI do not consume them, so build that screen before you tell users the codes work.
  • The issuer. BetterAuthConfig has a totpIssuer option, but the bootstrap code that builds the instance does not set it in this version, so authenticator apps show Boilerplate until you pass your brand name.
  • The secret protects the TOTP seeds. Rotating BETTER_AUTH_SECRET in place makes stored seeds unreadable. See BETTER_AUTH_SECRET: how to generate and rotate it.

Frequently asked questions

Does Better Auth support authenticator apps like Google Authenticator or 1Password?

Yes. The totpURI is a standard otpauth:// URI with a 6-digit, 30-second code, which those apps and Authy all accept.

Why is twoFactorEnabled still false after enable?

By design. It flips on the first successful verifyTotp, unless you set skipVerificationOnEnable: true.

Can I enforce 2FA for everyone?

The plugin does not have a "required" switch. You enforce it yourself, for example by redirecting users with twoFactorEnabled: false to an enrollment page.

Is a magic link a second factor?

No, it is a single factor proven by an inbox. That is why the hook above refuses it for users who enabled 2FA.

What happens if the user loses the device?

Backup codes, if you wired the screen for them, or an admin disabling 2FA on the account. Plan this before launch.


useDeploy ships Better Auth with TOTP and magic links already wired, the bypass hook above, and the routes and contracts around it. See the Better Auth integration and the auth docs.