Better Auth 2FA con TOTP: enrolá, verificá e iniciá sesión con un código
Sumá 2FA con TOTP a Better Auth: el plugin twoFactor, el flujo enable y verify, el desafío al iniciar sesión y los huecos que dejan los magic links.
Para sumar 2FA con TOTP a Better Auth, registrá el plugin twoFactor() en el
server, corré la migración del schema, llamá a enable (devuelve un totpURI
para dibujar como QR y un set de códigos de respaldo) y después a verifyTotp
con el primer código de la app autenticadora. A partir de ahí, el login con
contraseña responde twoFactorRedirect: true en vez de una sesión hasta que el
usuario manda un código válido. Dos detalles definen si queda realmente seguro:
el 2FA no se activa hasta que se verifica el primer código, y los magic links y
el login social no pasan por él. Esta guía está chequeada contra
[email protected], la versión que trae useDeploy, y contra su código fuente.
Setup de server y cliente
La doc oficial del plugin da el setup mínimo:
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()],
});El plugin agrega un flag twoFactorEnabled al usuario y una tabla twoFactor
(secret, backupCodes, verified). Generá la migración con el CLI de Better
Auth, o copiá los modelos a tu schema de Prisma a mano, como hace useDeploy
(model TwoFactor en apps/server/prisma/schema.prisma).
El flujo de enrolamiento
El enrolamiento son dos llamadas, y la segunda es la que todos se olvidan.
// 1. Empezar el enrolamiento (requiere sesión; pide la contraseña en cuentas con credenciales)
const { data } = await authClient.twoFactor.enable({ password });
// data = { totpURI, backupCodes }
// 2. El usuario escanea el QR y prueba que funciona
await authClient.twoFactor.verifyTotp({ code: '012345' });Lo que hace el código de 1.6.9 (plugins/two-factor/index.mjs,
plugins/two-factor/totp/index.mjs):
enablegenera un secreto aleatorio de 32 caracteres, lo encripta conBETTER_AUTH_SECRETy lo guarda converified: false. También genera códigos de respaldo, guardados encriptados por defecto (storeBackupCodes: 'encrypted').twoFactorEnabledsigue enfalsehasta queverifyTotptiene éxito. Si el usuario escanea el QR y se va, no queda atrapado en un 2FA a medio armar.- El primer
verifyTotpexitoso pasatwoFactorEnabledatrue, marca la fila como verificada y reemplaza la sesión por una nueva. - La opción
skipVerificationOnEnable: trueactiva el 2FA al instante. Dejala enfalse: con ella prendida, un error al configurar el autenticador deja al usuario afuera.
El totpURI contiene el secreto compartido, así que dibujá el QR en local. No le
mandes el URI a un servicio de QR de terceros.
Defaults que conviene saber: 6 dígitos, período de 30 segundos, y el verificador
acepta el código del período anterior y del siguiente al actual. El issuer es el
nombre que muestra la app autenticadora y por defecto es tu appName.
El desafío al iniciar sesión
Cuando un usuario con 2FA se loguea con email y contraseña, Better Auth no crea
una sesión. En el código borra la sesión que acababa de armar, setea una cookie
firmada two_factor que dura 10 minutos por defecto (twoFactorCookieMaxAge) y
responde:
{ "twoFactorRedirect": true, "twoFactorMethods": ["totp"] }El cliente pide un código y llama a twoFactor.verifyTotp. La cookie del
desafío es lo que prueba que el primer factor pasó, así que la segunda llamada no
lleva contraseña. Con trustDevice: true se saltea el desafío en ese dispositivo
durante 30 días (trustDeviceMaxAge, 2592000 segundos).
En el cliente podés manejar el redirect con onTwoFactorRedirect dentro de
twoFactorClient({ ... }), que evita la recarga completa de página que provoca
twoFactorPage.
Lo que el 2FA no cubre
Esta es la parte que la doc apenas menciona en una línea. En 1.6.9 el plugin
intercepta /sign-in/email, /sign-in/username y /sign-in/phone-number. Las
otras formas de generar una sesión no tienen desafío:
- la verificación de magic link (
/magic-link/verify) - el login social y el callback de OAuth
- los passkeys (son un segundo factor por sí solos, así que ese suele estar bien)
Si un usuario tiene TOTP activado y además hay flujo de magic link, quien llegue a
su casilla de mail entra sin código. Cerrá el hueco con un hook after que rechace
las sesiones de un solo factor para esos usuarios.
Cómo lo resuelve useDeploy
El setup del plugin está en
apps/server/src/modules/iam/infrastructure/auth/better-auth.adapter.ts:
twoFactor({
issuer: cfg.totpIssuer ?? 'Boilerplate',
skipVerificationOnEnable: false,
}),Y el hook que cierra el bypass de magic link y login social, en el mismo archivo:
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`);
}),
},Para un usuario con 2FA, un magic link o un login con Google crea una sesión, el
hook la borra y el navegador aterriza en /login?error=two-factor-required. Esos
usuarios entran con contraseña más TOTP, o con un passkey. El ?. importa: si no
se creó una sesión, newSession no está definido.
Arriba del plugin, useDeploy envuelve el flujo en sus propias rutas de Express
(apps/server/src/modules/iam/interfaces/http/auth.routes.ts):
| Ruta | Auth | Qué hace |
|---|---|---|
POST /api/v1/auth/2fa/enroll | sesión | enableTwoFactor, devuelve el URI, un QR ya renderizado como data URL y los códigos de respaldo |
POST /api/v1/auth/2fa/verify | sesión | verifyTOTP, activa el 2FA con el primer código |
POST /api/v1/auth/2fa/disable | sesión | disableTwoFactor, exige la contraseña |
POST /api/v1/auth/2fa/verify-sign-in | cookie del desafío | segundo paso del login, acepta { code, trustDevice? } |
Enroll, verify y disable se rechazan para API keys y mientras un admin de la
plataforma está impersonando al usuario. El código se valida como exactamente
seis dígitos con el schema Zod compartido de @app/contracts.
Cosas a saber antes de lanzarlo
- Fuerza bruta sobre el código. Better Auth limita
/two-factor/*a 3 requests cada 10 segundos, pero solo para requests HTTP a su propio handler y solo en producción por defecto. useDeploy llama al plugin víaauth.api, que se saltea ese limiter, así queverify-sign-inqueda detrás de los limiters por IP de la propia app. El mismo chequeo se puede alcanzar de forma nativa en/api/auth/two-factor/verify-totp, limitado a unas 18 requests por minuto por IP. Todas las rutas se limitan por IP; ninguna por cuenta. Mirá rate limiting en Better Auth (en inglés). - Códigos de respaldo. Se generan y se devuelven al enrolar, y el plugin tiene
un endpoint
verify-backup-code. Las rutas y la UI de useDeploy no lo consumen, así que armá esa pantalla antes de decirle a los usuarios que los códigos sirven. - El issuer.
BetterAuthConfigtiene una opcióntotpIssuer, pero el código de bootstrap que arma la instancia no la setea en esta versión, así que las apps autenticadoras muestranBoilerplatehasta que pases el nombre de tu marca. - El secreto protege las semillas TOTP. Rotar
BETTER_AUTH_SECRETen el lugar vuelve ilegibles las semillas guardadas. Mirá BETTER_AUTH_SECRET: cómo generarlo y rotarlo (en inglés).
Preguntas frecuentes
¿Better Auth funciona con Google Authenticator o 1Password?
Sí. El totpURI es un URI otpauth:// estándar con código de 6 dígitos y 30 segundos, que aceptan esas apps y también Authy.
¿Por qué twoFactorEnabled sigue en false después de enable?
Es a propósito. Pasa a true con el primer verifyTotp exitoso, salvo que setees skipVerificationOnEnable: true.
¿Puedo obligar a todos a usar 2FA?
El plugin no tiene un switch de "obligatorio". Lo forzás vos, por ejemplo redirigiendo a los usuarios con twoFactorEnabled: false a una pantalla de enrolamiento.
¿Un magic link cuenta como segundo factor?
No, es un solo factor probado por una casilla de mail. Por eso el hook de arriba lo rechaza para quienes activaron 2FA.
¿Y si el usuario pierde el dispositivo?
Con los códigos de respaldo, si armaste la pantalla, o con un admin desactivando el 2FA de la cuenta. Planificá esto antes de lanzar.
useDeploy trae Better Auth con TOTP y magic links ya conectados, el hook de arriba y las rutas y contratos alrededor. Mirá la integración con Better Auth y la documentación de auth.