2FA en Laravel 13: autenticación de dos factores con TOTP y Google Authenticator
Un código TOTP de 6 dígitos que caduca cada 30 segundos convierte una contraseña robada en un callejón sin salida. En Laravel 13 puedes añadir autenticación de dos factores a un login existente con un paquete y una migración, o activarla de fábrica con Fortify; esta guía cubre ambos caminos.
Qué es la 2FA con TOTP y por qué la necesitas
Tener solo contraseña ya no alcanza. Si un panel de administración o un área de clientes filtra credenciales —por phishing, reutilización de contraseñas o una brecha en otro servicio—, el atacante entra directo. La 2FA con TOTP corta ese problema: aunque alguien robe la contraseña, necesita además el código temporal que genera la app del usuario.
El estándar RFC 6238: códigos de 6 dígitos que caducan en 30 segundos
El TOTP (RFC 6238) deriva del HOTP (RFC 4226): con un secreto compartido y la hora actual genera un código de 6 dígitos válido por 30 segundos. Servidor y app calculan el mismo código, así que no hace falta conexión ni SMS: solo reloj sincronizado. El alta comparte el secreto en un QR con URL otpauth:// que apps como Google Authenticator o Authy escanean para generar códigos sin conexión.
Dos caminos para 2FA en Laravel 13
Opción A: paquete sobre tu login actual (pragmarx/google2fa-laravel)
Si tu app ya tiene login propio por contraseña y sesión —con roles, panel de admin, lo que sea—, el camino más quirúrgico es añadir el segundo factor con pragmarx/google2fa-laravel, la integración Laravel del paquete Google2FA de PHP. No reescribes la autenticación: guardas un secreto por usuario, muestras un QR y compruebas el código tras la contraseña.
Opción B: Fortify y Jetstream con 2FA integrada de fábrica
Laravel Fortify trae 2FA TOTP de primera mano: columnas cifradas en el modelo User, endpoints de activación, QR, confirmación y códigos de respaldo, y una vista de challenge en el login. Jetstream, el starter kit construido sobre Fortify, añade la interfaz lista. Si partes de un starter kit o aceptas su estructura, es lo menos que tienes que mantener.
Cómo elegir según el proyecto
La regla práctica: login propio consolidado, Opción A; proyecto nuevo o dispuesto a adoptar la estructura de Fortify/Jetstream, Opción B. Ambas producen el mismo resultado: TOTP compatible con las apps de siempre. Esta guía construye la A paso a paso y muestra qué ofrece la B.
Instalar y preparar el segundo factor
composer require pragmarx/google2fa-laravel
composer require pragmarx/google2fa-laravelEl paquete se registra solo por auto-discovery del service provider. Para pintar el QR de alta necesitas además una librería generadora, por ejemplo bacon/bacon-qr-code; la alternativa sin dependencia extra es mostrar el secreto en texto para que el usuario lo escriba en la app.
Migración: la columna google2fa_secret en la tabla de usuarios
Schema::table('users', function (Blueprint $table) {
$table->string('google2fa_secret')->nullable()->after('password');
});El secreto se guarda por usuario; en producción conviene cifrarlo antes de persistir. La columna nullable permite que los usuarios sin 2FA sigan entrando con normalidad.
Generar el secreto con Google2FA::generateSecretKey()
use PragmaRX\Google2FA\Google2FA;
$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey();Ese secreto de 32 caracteres base32 es la semilla compartida: se lo das al usuario una sola vez en el alta, idealmente por QR, y a partir de ahí la app y tu servidor calculan los mismos códigos. Nunca lo muestres después ni lo envíes por canales inseguros.
Alta del usuario: QR y confirmación del primer código
Construir la URL otpauth:// y pintar el código QR
$url = $google2fa->getQRCodeUrl('MiApp', $user->email, $secret);
// otpauth://totp/MiApp:usuario@correo.com?secret=...&issuer=MiAppCon la URL otpauth:// generas el QR y lo pintas en una vista de alta con pasos claros: el QR grande y el secreto oculto hasta confirmar. El issuer debe coincidir con el nombre que muestra la app del usuario para que reconozca la cuenta.
Confirmar un código antes de activar: no dejar al usuario fuera
El error clásico es activar la 2FA en cuanto se genera el QR: si el usuario escanea mal, queda fuera de su cuenta. La activación real ocurre solo cuando envía un código válido generado por su app; ese primer código demuestra que el secreto quedó bien guardado.
El formulario de verificación en el panel
El formulario pide el código de 6 dígitos; al confirmarlo guardas el secreto y marcas la 2FA como activa. Si el código no verifica, no guardas nada y muestras el error. Así el usuario nunca activa un secreto que no tiene realmente en su app.
Exigir el código en el login
Detectar al usuario con 2FA activa tras la contraseña
Tras validar la contraseña, comprueba si el usuario tiene la 2FA activa. Si no, continúa la sesión normal; si sí, no abras sesión todavía: mantén al usuario a medias (con un flag pending_2fa en sesión) y redirige a la pantalla de challenge.
La pantalla de challenge y la verificación con Google2FA::verifyKey()
if (! $google2fa->verifyKey($user->google2fa_secret, $request->code)) {
return back()->withErrors(['code' => 'El código no es válido.']);
}
// código correcto: completar el login y limpiar el flag pendienteLa pantalla de challenge es un formulario mínimo con el campo del código. Al verificarlo completas el login: regeneras la sesión y limpias el estado pendiente. Exige el código en cada login nuevo y no lo guardes en la sesión de forma permanente.
Tolerancia de deriva de reloj (window) en la verificación
El reloj del teléfono puede desviarse unos segundos y hacer que un código válido llegue tarde. verifyKey() acepta un tercer parámetro de ventana: con window = 1 admites también el código anterior y el siguiente a la ventana actual, suficiente para absorber la deriva habitual sin abrir la puerta a códigos viejos.
Códigos de respaldo: el seguro contra el teléfono perdido
Generar códigos de un solo uso y guardarlos hasheados
Si el usuario pierde el teléfono, sin respaldo se queda fuera. Al activar la 2FA genera de 6 a 10 códigos de un solo uso, muéstralos una única vez y persístelos con hash, nunca en claro. Cada código usado se invalida.
Desactivar la 2FA desde el panel
La desactivación exige su propia verificación: pedir el código actual o un respaldo antes de borrar el secreto, para que un atacante con la sesión abierta no apague la protección. Tras desactivar, se limpia la columna.
La alternativa de fábrica: Fortify y Jetstream
Columnas cifradas two_factor_secret y two_factor_recovery_codes
Fortify incluye la 2FA TOTP por debajo (usa Google2FA) y cifra por ti: añade al modelo User los campos two_factor_secret y two_factor_recovery_codes cifrados. Basta publicar sus migraciones y añadir los traits al modelo.
Endpoints de activación, QR, confirmación y recovery codes
Fortify expone las rutas: /user/two-factor-authentication para activar o desactivar, /user/two-factor-qr-code para el QR, la confirmación con el primer código y /user/two-factor-recovery-codes para los respaldos. El alta también exige confirmar un código antes de activar.
La vista two-factor-challenge y el modo SPA con respuestas JSON
Cuando un usuario con 2FA activa hace login, Fortify redirige a two-factor-challenge para pedir el código o un recovery code. En modo SPA esas rutas responden en JSON, así un frontend Vue o React la integra sin recargar. Para exigirla solo a admins, condiciona el challenge según el rol.
Jetstream: la interfaz lista si partes de un starter kit
Jetstream, construido sobre Fortify, trae la página de seguridad del perfil con activación, QR, códigos de respaldo y dispositivos. Si aceptas su estructura, la 2FA llega casi sin código propio; con un login a medida, la Opción A es más limpia.
Probar el flujo con Pest
Test del login sin 2FA y con 2FA pendiente de código
Un test de Pest verifica que un usuario sin 2FA entra normal y que uno con 2FA activa que envía solo la contraseña no completa el login. Ese par cubre la regresión clave: no romper el login ni dejar pasar a quien debe dar el segundo factor.
Test de activación, código incorrecto y código de respaldo
Los tests de alta verifican que activar sin código válido no guarda el secreto, que un código correcto activa la 2FA y que el login rechaza uno erróneo; el TOTP esperado se calcula en el test con la misma librería. Un test de recovery code confirma que un respaldo sin usar completa el login y queda invalidado.
Conclusión
Añadir 2FA a un Laravel 13 con login propio es un cambio acotado: secreto por usuario, QR de alta con confirmación, comprobación tras la contraseña y códigos de respaldo. Si prefieres no mantener esa lógica, Fortify y Jetstream la traen resuelta. Con el segundo factor activo, una contraseña filtrada no basta para entrar. Para más, el blog tiene guías de passkeys, tokens de API y policies.