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.
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):
enablegenerates a random 32-character secret, encrypts it withBETTER_AUTH_SECRETand stores it withverified: false. It also generates backup codes, stored encrypted by default (storeBackupCodes: 'encrypted').twoFactorEnabledstaysfalseuntilverifyTotpsucceeds. A user who scans the QR and walks away is not locked into 2FA.- A successful first
verifyTotpflipstwoFactorEnabledtotrue, marks the row verified and swaps the session for a fresh one. - The option
skipVerificationOnEnable: trueactivates 2FA immediately. Leave itfalse: 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):
| Route | Auth | What it does |
|---|---|---|
POST /api/v1/auth/2fa/enroll | session | enableTwoFactor, returns the URI, a pre-rendered QR data URL and backup codes |
POST /api/v1/auth/2fa/verify | session | verifyTOTP, activates 2FA with the first code |
POST /api/v1/auth/2fa/disable | session | disableTwoFactor, requires the password |
POST /api/v1/auth/2fa/verify-sign-in | challenge cookie | second 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 throughauth.api, which skips that limiter, soverify-sign-insits 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-codeendpoint. useDeploy's own routes and UI do not consume them, so build that screen before you tell users the codes work. - The issuer.
BetterAuthConfighas atotpIssueroption, but the bootstrap code that builds the instance does not set it in this version, so authenticator apps showBoilerplateuntil you pass your brand name. - The secret protects the TOTP seeds. Rotating
BETTER_AUTH_SECRETin 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.