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.

MC
Martin Coll

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):

  • enable genera un secreto aleatorio de 32 caracteres, lo encripta con BETTER_AUTH_SECRET y lo guarda con verified: false. También genera códigos de respaldo, guardados encriptados por defecto (storeBackupCodes: 'encrypted').
  • twoFactorEnabled sigue en false hasta que verifyTotp tiene éxito. Si el usuario escanea el QR y se va, no queda atrapado en un 2FA a medio armar.
  • El primer verifyTotp exitoso pasa twoFactorEnabled a true, marca la fila como verificada y reemplaza la sesión por una nueva.
  • La opción skipVerificationOnEnable: true activa el 2FA al instante. Dejala en false: 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):

RutaAuthQué hace
POST /api/v1/auth/2fa/enrollsesiónenableTwoFactor, devuelve el URI, un QR ya renderizado como data URL y los códigos de respaldo
POST /api/v1/auth/2fa/verifysesiónverifyTOTP, activa el 2FA con el primer código
POST /api/v1/auth/2fa/disablesesióndisableTwoFactor, exige la contraseña
POST /api/v1/auth/2fa/verify-sign-incookie del desafíosegundo 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ía auth.api, que se saltea ese limiter, así que verify-sign-in queda 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. BetterAuthConfig tiene una opción totpIssuer, pero el código de bootstrap que arma la instancia no la setea en esta versión, así que las apps autenticadoras muestran Boilerplate hasta que pases el nombre de tu marca.
  • El secreto protege las semillas TOTP. Rotar BETTER_AUTH_SECRET en 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.