Autenticación de Dos Factores (2FA / TOTP)
Cómo implementar autenticación de dos factores con contraseñas de un solo uso basadas en tiempo (TOTP) para login seguro.
Visión General
Un usuario cae en phishing y su contraseña termina en un volcado de credenciales. Con autenticación de solo contraseña, esa cuenta está perdida para siempre. Con TOTP habilitado, el atacante todavía necesita un código que cambia cada 30 segundos y vive en un dispositivo físico. La contraseña robada se vuelve inútil por sí sola. Ese es todo el punto de la autenticación de dos factores: algo que sabes (contraseña) más algo que tienes (un autenticador que genera contraseñas de un solo uso basadas en tiempo según RFC 6238).
Esta receta te da el ciclo completo de enrolamiento y verificación en Python, JavaScript y Java: generación de secretos, construcción de URIs otpauth://, renderizado de códigos QR, verificación de tokens con ventana de tolerancia y emisión de códigos de respaldo. También cubre las partes que la mayoría de los ejemplos omiten: encriptar secretos en reposo, limitar intentos de verificación y qué hacer cuando un usuario pierde su teléfono.
Una cosa que TOTP no es: un sustituto de una seguridad de contraseñas decente. Los dos factores se apilan, y un eslabón débil arrastra a los dos. Una contraseña reutilizada o sin salt más un segundo factor fuerte sigue siendo un sistema débil. Primero resuelve el Hashing de Contraseñas, luego añade TOTP encima.
Cuándo Usar
Usa esta receta cuando:
- Protejas cuentas de usuario con un paso de verificación más allá de las contraseñas
- Construyas flujos de login para aplicaciones financieras, de salud o de administración
- Migres de autenticación solo-contraseña a autenticación multifactor (MFA). Consulta Hashing de Contraseñas para el almacenamiento de credenciales.
- Soportes apps de autenticación (Google Authenticator, Authy, Microsoft Authenticator)
Omítelo para APIs machine-to-machine (usa API keys o OAuth2 client credentials; ver Cuándo No Usar más abajo) y para herramientas internas donde todos son de confianza y la red está segmentada.
Solución
El flujo de enrolamiento tiene tres piezas del lado del servidor: un endpoint POST /2fa/enroll que genera un secreto y devuelve la URI de aprovisionamiento más el QR, un endpoint POST /2fa/confirm que verifica el primer código del usuario antes de marcar 2FA como activo (nunca almacenes el secreto como habilitado antes de que un código verifique; si no, los usuarios pueden bloquearse a sí mismos escaneando mal), y un paso POST /2fa/verify dentro de tu pipeline de login que corre después del check de contraseña. Los tres aparecen en el servicio de abajo, uno por lenguaje.
Python
import secrets
import pyotp
import qrcode
import io
import base64
class TOTPService:
def generate_secret(self) -> str:
return pyotp.random_base32()
def get_provisioning_uri(self, secret: str, user_email: str, issuer: str) -> str:
return pyotp.totp.TOTP(secret).provisioning_uri(
name=user_email,
issuer_name=issuer
)
def generate_qr_code(self, provisioning_uri: str) -> str:
"""Devuelve la imagen QR como PNG en base64 para <img src='data:...'>."""
img = qrcode.make(provisioning_uri)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode()
def verify_token(self, secret: str, token: str, window: int = 1) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=window)
def generate_backup_codes(self, count: int = 10) -> list[str]:
return [secrets.token_hex(4).upper() for _ in range(count)]
# Uso
service = TOTPService()
secret = service.generate_secret()
uri = service.get_provisioning_uri(secret, "user@example.com", "MyApp")
qr_b64 = service.generate_qr_code(uri)
is_valid = service.verify_token(secret, "123456")
backup_codes = service.generate_backup_codes()
JavaScript
import { authenticator } from "otplib";
import QRCode from "qrcode";
import crypto from "crypto";
class TOTPService {
generateSecret() {
return authenticator.generateSecret();
}
getProvisioningUri(secret, userEmail, issuer) {
return authenticator.keyuri(userEmail, issuer, secret);
}
async generateQRCode(provisioningUri) {
return QRCode.toDataURL(provisioningUri); // data:image/png;base64,... para <img src>
}
verifyToken(secret, token, window = 1) {
return authenticator.verify({ token, secret, window });
}
generateBackupCodes(count = 10) {
return Array.from({ length: count }, () =>
crypto.randomBytes(4).toString("hex").toUpperCase()
);
}
}
// Uso
const service = new TOTPService();
const secret = service.generateSecret();
const uri = service.getProvisioningUri(secret, "user@example.com", "MyApp");
const qrDataUrl = await service.generateQRCode(uri);
const isValid = service.verifyToken(secret, "123456");
const backupCodes = service.generateBackupCodes();
Java
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import dev.samstevens.totp.code.*;
import dev.samstevens.totp.qr.*;
import dev.samstevens.totp.secret.*;
import dev.samstevens.totp.time.*;
import java.awt.image.BufferedImage;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.*;
public class TOTPService {
private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
private final TimeProvider timeProvider = new SystemTimeProvider();
private final CodeGenerator codeGenerator = new DefaultCodeGenerator();
private final CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
public String generateSecret() {
return secretGenerator.generate();
}
public String getProvisioningUri(String secret, String userEmail, String issuer) {
// Codifica los valores del usuario — un email con & o = rompe la URI
String enc = StandardCharsets.UTF_8.toString();
return "otpauth://totp/" + URLEncoder.encode(issuer + ":" + userEmail, enc) +
"?secret=" + secret + "&issuer=" + URLEncoder.encode(issuer, enc);
}
public BufferedImage generateQRCode(String provisioningUri) throws Exception {
QRCodeWriter writer = new QRCodeWriter();
BitMatrix matrix = writer.encode(provisioningUri, BarcodeFormat.QR_CODE, 200, 200);
return MatrixToImageWriter.toBufferedImage(matrix); // BufferedImage, no byte[]
}
public boolean verifyToken(String secret, String token) {
return verifier.isValidCode(secret, token);
}
public List<String> generateBackupCodes(int count) {
SecureRandom random = new SecureRandom();
return IntStream.range(0, count)
.mapToObj(i -> String.format("%08X", random.nextInt()))
.toList();
}
}
Explicación
Dos flujos importan: el enrolamiento ocurre una vez, la verificación ocurre en cada login.
TOTP deriva un código de 6 dígitos a partir de un secreto compartido y el timestamp actual cortado en pasos de 30 segundos (definido en RFC 6238, construido sobre el mecanismo de contador de HOTP en RFC 4226). Ambas partes calculan el mismo HMAC-SHA1 — mientras los secretos coincidan y los relojes estén cerca, los códigos también coinciden.
Aprovisionamiento por QR envuelve el secreto en una URI otpauth://totp/Issuer:user@example.com?secret=BASE32&issuer=Issuer. La app de autenticación la escanea, guarda el secreto y empieza a generar códigos. El secreto cruza la red exactamente una vez, en ese QR — trata la página de enrolamiento como sensible.
Ventana de verificación acepta códigos de pasos temporales adyacentes para absorber desviación de reloj. window=1 significa ±30 segundos, que cubre casi cualquier teléfono real. Ir más ancho facilita marginalmente la fuerza bruta y oculta problemas reales de NTP.
Códigos de respaldo son códigos de recuperación de un solo uso. Genera 8-10, muéstralos una vez, guarda solo sus hashes (el mismo tratamiento que las contraseñas; ver Hashing de Contraseñas), y quema cada uno al usarlo.
La URI misma merece una segunda mirada. otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp tiene cuatro partes: el esquema (otpauth://totp/), la etiqueta (Issuer:account), el secreto base32 y los parámetros. La etiqueta y el issuer son strings de display para la app. El secreto es la única parte que importa criptográficamente, por eso filtrar el QR significa filtrar el factor.
Almacenar el secreto: después de que el paso de confirmación tenga éxito, persiste user_id → encrypted_secret con la clave de encriptación fuera de la base de datos (KMS, vault, o como mínimo una clave inyectada por env). Si tienes que elegir entre encriptar secretos y hashearlos: encripta. Necesitas el plaintext de vuelta en cada verificación, así que se requiere encriptación reversible; no es una contraseña.
Elegir la ventana de tolerancia: valid_window=1 acepta códigos del paso anterior, el actual y el siguiente de 30 segundos, así que tres códigos válidos en cualquier instante en lugar de uno. Eso triplica la superficie de fuerza bruta por intento, por eso la ventana se queda en 1 y el rate limit hace el trabajo real.
Variantes
| Método | Librería / Estándar | Ideal Para |
|---|---|---|
| TOTP | pyotp, otplib, samstevens/totp | La mayoría de aplicaciones — gratis, offline, basado en app |
| SMS OTP | Twilio, AWS SNS | Usuarios sin smartphones (vulnerable a SIM-swap) |
| WebAuthn / FIDO2 | py_webauthn, fido2-lib | Llaves físicas resistentes a phishing |
| Push Notification | Firebase, OneSignal | Aprobación sin fricción en dispositivos de confianza |
| Email OTP | Implementación propia | Fallback cuando TOTP no está disponible |
TOTP es el default por buena razón: no cuesta nada, funciona offline, no necesita cuenta de terceros y todo autenticador importante lo habla. SMS es la opción mainstream más débil — el SIM-swapping lo derrota, y el NIST lo ha deprecado como segundo factor. WebAuthn es el más fuerte pero añade fricción de enrolamiento y requisitos de navegador/hardware. La mayoría de sistemas en producción ofrecen TOTP más códigos de respaldo, con WebAuthn como tier premium.
Dos distinciones más que vale conocer. TOTP vs HOTP: TOTP es HOTP con el contador reemplazado por un timestamp; misma matemática, pieza móvil diferente. Los códigos HOTP permanecen válidos hasta ser consumidos, lo que los hace mejores para flujos dirigidos por eventos (un key fob con botón) y peores para cualquier cosa tipeada en una red donde el código puede quedar sin usar. TOTP vs push: las notificaciones push sacan la decisión fuera de banda. El usuario aprueba en un dispositivo de confianza en lugar de tipear un código. Menos phishable que TOTP pero requiere dependencia de un vendor y un dispositivo que confíe en el canal push.
Lo que Funciona — y lo que Rompe
Hazlo:
- Encripta los secretos en reposo — AES-256-GCM o un vault de secretos, nunca texto plano. Una fila de DB filtrada con un secreto legible le entrega al atacante ambos factores.
- Limita los intentos de verificación: 5 intentos por 5 minutos, luego bloquea o retrasa. Seis dígitos son solo 1M de combinaciones, así que los intentos ilimitados terminan en fuerza bruta.
- Proporciona códigos de respaldo al enrolar: 8-10 códigos de un solo uso, mostrados una vez, almacenados hasheados.
- Permite re-enrolamiento: los usuarios cambian de teléfono. Protege el re-enrolamiento con confirmación por email para que una sesión robada no pueda rotar el secreto en silencio.
- Loguea eventos de 2FA: enrolamiento, éxito/fallo de verificación, uso de códigos de respaldo, eventos de desactivación. Es el rastro de auditoría que detecta intentos de account takeover.
No lo hagas:
- Almacenar secretos en texto plano — el error más común de la lista y el peor del que recuperarse.
- Saltarte la validación del formato del token. Verifica que sean 6 dígitos antes de llamar al verificador; la entrada basura desperdicia un slot del rate limit de todas formas.
- Permitir intentos de verificación ilimitados; el código solo tiene 1M de combinaciones.
- Reutilizar códigos de respaldo. Marca cada uno como consumido en el momento en que verifica.
- Enviar el secreto por canales inseguros. La página de enrolamiento con el QR debe ser HTTPS y estar ligada a la sesión.
- Hacer de SMS el factor principal sin advertir a los usuarios sobre el SIM-swapping.
- Mostrar errores de TOTP que distingan “usuario no encontrado” de “código incorrecto”; eso es enumeración de cuentas.
- Confiar en
window > 1a la ligera. Cada ventana extra abre un código más que es válido ahora mismo.
Checklist de Producción
- Secretos TOTP encriptados en reposo (AES-256-GCM o vault)
- Endpoint de verificación con rate limit (≤5 intentos / 5 min por cuenta)
- Códigos de respaldo generados al enrolar, almacenados hasheados, de un solo uso
- Consumo de códigos de respaldo logueado y alertado
-
valid_window=1(o justificado si es mayor) documentado - Reloj del servidor sincronizado vía NTP; monitoreo de drift en su lugar
- Re-enrolamiento requiere confirmación por email
- Desactivar 2FA requiere re-autenticación o verificación de identidad
- Página de enrolamiento solo HTTPS, ligada a sesión, secreto nunca logueado
- Eventos 2FA (enrolar/verificar/usar-respaldo/desactivar) en el log de auditoría
- Flujo de recuperación documentado y probado (dispositivo perdido + códigos perdidos)
Consideraciones de Seguridad
- Fuerza bruta sobre 6 dígitos: 1M de combinaciones suena a mucho hasta que permites intentos ilimitados. Limita la verificación con fuerza; el cheat sheet de MFA de OWASP recomienda lockouts más alertas.
- Replay de códigos: quema cada código al usarlo. Marca el contador/timestamp como consumido después de una verificación exitosa; si no, un código interceptado se replayea dentro de su ventana de 30-90s.
- Secreto en reposo: mantenlo encriptado, fuera de los logs, fuera de los mensajes de error. El secreto es el segundo factor; un volcado de base de datos con secretos en plaintext lo derrota por completo.
- SIM-swapping en el fallback SMS: si SMS es método de respaldo, un atacante que porta el número recibe el código. Prefiere códigos de respaldo TOTP sobre recuperación por SMS.
- Phishing: los códigos TOTP todavía se pueden phishear en tiempo real (el atacante los retransmite al instante). Solo WebAuthn es resistente a phishing; ese es el camino de upgrade.
- Abuso de recuperación: el flujo de “perdí mi teléfono” es la superficie de ataque real. Requiere prueba de identidad proporcional al valor de la cuenta — un flujo de recuperación débil evade un 2FA fuerte.
- Manipulación de enrolamiento: un atacante con una sesión robada que puede re-enrolar un nuevo dispositivo en silencio posee la cuenta permanentemente. El re-enrolamiento debe notificar al usuario por email.
- Exposición de la página QR: el QR contiene el secreto. No lo cachees, no lo loguees, no lo sirvas sin autenticación.
- Límites del modelo de secreto compartido: a diferencia de esquemas asimétricos (las claves públicas de WebAuthn), un secreto robado del servidor permite al atacante generar códigos offline. Esa asimetría es por qué los checklists de despliegue TOTP se obsesionan con la encriptación en reposo: la brecha de tu DB es la brecha de todos los factores enrolados a la vez.
- Almacenamiento de códigos de respaldo: hashear códigos de respaldo como contraseñas no es pulido opcional. Un atacante que lee los códigos de respaldo en plaintext de tus usuarios tiene una vía de recuperación que evade por completo el endpoint de verificación con rate limit.
Rendimiento y Costos
La verificación TOTP es casi gratis: un HMAC-SHA1 sobre un contador de 30 segundos cuesta microsegundos, así que un solo núcleo verifica decenas de miles de códigos por segundo. La generación del QR (~50ms para un PNG de 200×200) ocurre una vez por enrolamiento. El costo real no es el cómputo:
| Componente | Costo | Notas |
|---|---|---|
| Librerías TOTP (pyotp, otplib) | $0 | Open source, sin costo por petición |
| Almacenamiento de secretos | ~$0 | Unos cientos de bytes por usuario en tu DB actual |
| Contador de rate limit (Redis) | $10-$25/mes | Solo si ya no corres Redis |
| SMS OTP (Twilio) | ~$0.05/mensaje | Por qué SMS debería ser fallback, no default |
| MFA hospedado (Auth0/Okta) | $35-$800+/mes | vs. implementar TOTP por ~$0 |
Para la mayoría de equipos, TOTP self-hosted es un error de redondeo. La decisión de costo es si pagar a un proveedor de identidad por todo el stack de auth, no por el 2FA específicamente.
Monitoreo y Testing
Vigila tres señales. Tasa de verificaciones fallidas: un pico por usuario significa fuerza bruta o un usuario confundido; un pico por IP significa un ataque. Uso de códigos de respaldo: el uso legítimo es raro; una ráfaga sugiere códigos comprometidos o abuso de recuperación. Tasa de enrolamiento 2FA: por debajo del ~30% de usuarios activos, tu segundo factor no está protegiendo a la población que importa; incita al enrolamiento en el login.
Prueba cuatro escenarios. Round-trip de enrolamiento: genera un secreto, construye la URI y verifica un código TOTP real derivado de ese mismo secreto (pyotp y otplib exponen totp.now() para esto). Borde de ventana: un código del paso anterior de 30 segundos pasa con window=1; uno de dos pasos atrás debe fallar. Rate limiting: el 6º intento rápido recibe un 429, no un veredicto sobre el código. Código de respaldo: funciona una vez, luego el mismo código se replayea como rechazo.
Troubleshooting
- Todos los códigos rechazados. Casi siempre es drift de reloj. Revisa primero el NTP del servidor. Si el servidor está bien, el reloj del teléfono del usuario está mal; amplía
valid_windowa 1 (si no lo está) o guíalo a re-sincronizar. - El código QR no escanea. La URI está malformada. Revisa el URL-encoding del issuer/email (espacios,
@,&rompen la construcción ingenua de strings; el ejemplo Java de arriba los codifica). - Los códigos funcionan en un dispositivo pero no en otro. Los dos dispositivos están guardando secretos diferentes. El usuario se enroló dos veces o escaneó un QR viejo. Re-enrola limpio e invalida el secreto anterior.
- Usuario completamente bloqueado. Primero los códigos de respaldo; si también se perdieron, el flujo de recuperación decide si la cuenta es recuperable — ese flujo debe verificar la identidad con más fuerza que el 2FA que reemplaza.
- La verificación TOTP es lenta bajo carga. No es el HMAC — es el contador del rate limit o el lookup en base de datos en la ruta de verificación. Cachea la lectura del secreto; mantén el contador de intentos en Redis.
- El enrolamiento funciona pero los códigos nunca coinciden. El servidor guardó el secreto incorrectamente. Una causa común es almacenar la URI de aprovisionamiento en lugar del secreto base32 crudo, o codificarlo dos veces. Guarda exactamente lo que devolvió
generate_secret().
Cuándo No Usar Este Enfoque
- APIs machine-to-machine — un servicio no tiene un teléfono del que leer un código. Usa API keys, mTLS u OAuth2 client credentials.
- Herramientas internas en redes de confianza — cuando el acceso ya está limitado por VPN y todos son de confianza, el 2FA añade fricción sin recortar mucho riesgo.
- APIs públicas de solo lectura — sin cuentas de usuario no hay nada que proteger con un segundo factor.
- Prototipos y MVPs — resuelve primero el núcleo de auth; añadir 2FA sobre un flujo de login inseguro no protege nada.
- Flujos de sesión de alta frecuencia — si tus usuarios re-autentican decenas de veces al día, TOTP por login destruye la experiencia. Condiciona el factor a nuevos dispositivos, nuevas IPs o acciones elevadas (pagos, exports, cambios de configuración) en lugar de cada login.
Ver también
- Hashing de Contraseñas — el almacenamiento del primer factor, y el modelo para hashear códigos de respaldo
- Autenticación por Magic Link — verificación por email para flujos de recuperación
- Autenticación JWT — emitir tokens después de que pase el segundo factor
- Rate Limiting — proteger el endpoint de verificación de la fuerza bruta
- RFC 6238 — TOTP — la especificación del algoritmo
- RFC 4226 — HOTP — el predecesor basado en contador sobre el que TOTP se construye
- OWASP MFA Cheat Sheet — lockouts, recuperación y guía de implementación
Código companion: recursos companion de 2FA — endpoints enroll + verify, códigos de respaldo hasheados y una suite de pytest que cubre los escenarios de arriba.
Preguntas frecuentes
¿Cómo manejo la desviación de reloj entre servidor y cliente?
Usa valid_window=1 (±30 segundos). Absorbe la desviación de casi cualquier teléfono real. Mantén tu servidor en NTP. Si la desviación aún excede una ventana, el reloj del dispositivo del usuario está muy mal; pídele re-sincronizar o usa un código de respaldo.
¿Es mejor TOTP o SMS para 2FA?
TOTP, sin discusión. SMS se puede phishear en tránsito, derrotar vía SIM-swap, cuesta por mensaje, y el NIST lo deprecó como segundo factor. TOTP no cuesta nada, funciona offline y vive en una app. SMS solo sobrevive como fallback para usuarios sin smartphones.
¿Puedo usar el mismo secreto TOTP en múltiples dispositivos?
Sí — escanear el mismo QR enrola el mismo secreto en cada dispositivo. Por seguridad, registra cada dispositivo enrolado en la cuenta del usuario y hazlos revocables individualmente; perder un teléfono no debería requerir re-enrolarlos todos.
¿Qué pasa si un usuario pierde su dispositivo de autenticación?
Códigos de respaldo, generados al enrolar. Si también se pierden, el flujo de recuperación debe verificar la identidad con más rigor que el 2FA que reemplaza; Magic Links más un delay y notificación es el patrón común. Un flujo de recuperación débil es el bypass de 2FA más común en el mundo real.
¿Es TOTP resistente a phishing?
No. Un sitio de phishing corriendo en tiempo real puede retransmitir el código en el momento en que lo tipeas. TOTP detiene el credential-stuffing y las filtraciones pasivas, no el phishing activo. Si la resistencia a phishing es el requisito, ese es territorio de WebAuthn/FIDO2: llaves ligadas a hardware que no se pueden replayear en otro origen.
¿Deberían rotarse los secretos TOTP?
No rutinariamente. Un secreto se mantiene seguro por dónde lo guardas, no por cuánto tiempo lleva ahí. Rota en eventos de enrolamiento (nuevo dispositivo), compromiso sospechado, o al migrar el store de secretos. La rotación rutinaria entrena a los usuarios a esperar prompts de enrolamiento, que los atacantes explotan.
Recursos Relacionados
Autenticación JWT
Firma, verifica y refresca JWTs en Python, JavaScript y Java — flujos access/refresh, pares de claves RS256 y revocación de tokens bien hecha.
RecipeInicio de Sesión OAuth 2.0
Cómo implementar autenticación OAuth 2.0 con Google, GitHub y otros proveedores. Cubre el flujo Authorization Code con PKCE, validación de state y refresh tokens.
RecipeCómo hashear contraseñas (Python, JavaScript, Java)
Aprendé a hashear y verificar contraseñas con bcrypt, Argon2 y PBKDF2. Ejemplos prácticos en Python, JavaScript y Java, más pasos de migración y trade-offs de parámetros.
RecipeMiddleware: Interceptores HTTP
Cómo implementar middleware de request/response para logging, auth y manejo de errores en Python, JavaScript y Java.
RecipeValidación de Subida de Archivos
Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.
RecipeImplementar Login Sin Contraseña con Magic Links
Cómo construir autenticación passwordless segura usando links mágicos de tiempo limitado enviados por email, con generación de tokens, validación y prevención de ataques replay.