Cada vez que abres una app autenticadora y lees un código de seis dígitos, se está ejecutando en tu teléfono una pieza de criptografía pequeña pero elegante. Ese código no se descargó de un servidor, no llegó por SMS y no se almacenó de antemano en ningún sitio. Se calculó en el momento a partir de dos ingredientes: un secreto que tú y el servidor compartís, y la hora actual. Este artículo recorre todo el mecanismo de la contraseña de un solo uso basada en tiempo (TOTP) definido en RFC 6238, desde el instante en que escaneas el código QR de alta hasta el momento en que el servidor acepta tu código al iniciar sesión. Si quieres ver cada uno de estos pasos ejecutarse sobre un secreto que tú aportes, nuestro generador TOTP calcula los códigos en tu navegador y muestra los valores intermedios.
La Idea General: Un Secreto Compartido Más el Reloj
TOTP es un esquema simétrico. No hay clave pública, ni certificado, ni ida y vuelta por red durante la generación del código. Tanto tu autenticador como el servicio comparten una única clave secreta, establecida una sola vez en el alta. A partir de esa clave, cualquiera de las dos partes puede derivar de forma independiente el mismo código numérico corto para cualquier momento dado. Como ambas partes leen el mismo reloj y tienen la misma clave, llegan a los mismos seis dígitos sin haber intercambiado nunca ese código de antemano. El código cambia cada 30 segundos por defecto, así que aunque un atacante capture uno, queda inservible casi de inmediato.
TOTP es formalmente una extensión de un algoritmo más antiguo, HOTP (contraseña de un solo uso basada en HMAC), especificado en RFC 4226. HOTP deriva un código de un secreto y de un contador que se incrementa en uno cada vez que se usa un código. TOTP conserva toda la construcción de HOTP pero sustituye ese contador de eventos por un valor derivado de la hora actual. Entender esta relación es la clave para entender TOTP: todo lo que hace HOTP lo hace también TOTP, y la única diferencia es de dónde sale el contador.
Alta: Establecer el Secreto Compartido
Cuando activas la autenticación de doble factor en una cuenta, el servidor genera un secreto aleatorio, normalmente 160 bits (20 bytes) de aleatoriedad criptográficamente segura. Ese secreto tiene que llegar a tu app autenticadora, y la forma estándar de moverlo es un código QR. El QR codifica una URI otpauth:// siguiendo el formato Key URI de Google Authenticator, por ejemplo otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30. La anatomía completa de esa URI y del código QR que la transporta se cubre en nuestra guía complementaria, Anatomía de un código QR de 2FA.
El secreto dentro de esa URI está codificado en base32 según define RFC 4648. Base32 usa las 26 letras mayúsculas de la A a la Z y los dígitos del 2 al 7 — un alfabeto de 32 símbolos donde cada carácter representa exactamente cinco bits. Se usa base32 en lugar del más compacto base64 por una razón práctica: el alfabeto no contiene minúsculas, ni caracteres fáciles de confundir, ni símbolos, de modo que una persona puede leer un secreto en voz alta o teclearlo a mano sin ambigüedad cuando no hay cámara disponible. Tu app decodifica la cadena base32 de vuelta a los bytes crudos originales y los almacena. A partir de este punto, tanto la app como el servidor tienen bytes de clave idénticos, y el alta queda completa. No hace falta transmitir nada más, nunca jamás.
El Paso de Tiempo: Convertir el Reloj en un Contador
Para generar un código, TOTP primero convierte la hora actual en un contador entero. Parte del tiempo Unix — el número de segundos transcurridos desde la época, la medianoche UTC del 1 de enero de 1970 — y lo divide entre el período, la longitud del paso de tiempo en segundos. El período es de 30 segundos en casi todos los despliegues reales. El contador es el suelo (floor) de esa división: contador = floor(segundos_unix / período). Así, durante los 30 segundos que van de las 12:00:00 a las 12:00:29 el contador mantiene un valor, y a las 12:00:30 se incrementa en uno y mantiene ese nuevo valor los siguientes 30 segundos.
Por eso todos los dispositivos implicados deben coincidir en la hora actual y por eso las apps autenticadoras insisten en un reloj preciso. El contador es una función pura del reloj: si la hora de tu teléfono se desvía 45 segundos, calculará el contador de un paso distinto al del servidor y producirá un código que el servidor no espera. RFC 6238 también permite un desplazamiento de inicio llamado T0, que por defecto es cero (la época Unix), así que en la práctica la fórmula es simplemente los segundos Unix transcurridos divididos entre el período. El contador resultante se formatea entonces como un entero big-endian de 8 bytes (64 bits), que es el formato de entrada exacto que espera el algoritmo HOTP subyacente. Ese bloque de 8 bytes es lo único que cambia de un paso de tiempo al siguiente — la clave permanece constante.
El HMAC: Mezclar Secreto y Contador
Con el contador de 8 bytes en la mano, TOTP calcula un hash con clave. Ejecuta HMAC (código de autenticación de mensaje basado en hash) usando el secreto compartido como clave y el contador de 8 bytes como mensaje. La función hash por defecto es SHA-1, que produce una salida de 20 bytes (160 bits). RFC 6238 permite explícitamente SHA-256 y SHA-512 como alternativas, produciendo salidas de 32 y 64 bytes respectivamente, y el parámetro algorithm de la URI de alta registra cuál está en uso.
Conviene atender una preocupación habitual: SHA-1 está roto en cuanto a resistencia a colisiones, ¿por qué es aceptable aquí? La respuesta es que TOTP no depende en absoluto de la resistencia a colisiones de SHA-1. Depende de la propiedad de HMAC-SHA-1 como función pseudoaleatoria con clave, y no se conoce ningún ataque práctico contra HMAC-SHA-1 en ese papel. Un atacante que no tenga la clave secreta no puede predecir la salida del HMAC para ningún contador. La salida de este paso es una cadena impredecible de 20 bytes que depende conjuntamente del secreto y del paso de tiempo exacto. Pero una cadena de 20 bytes no es un código de acceso cómodo para una persona, así que un paso más la comprime.

Truncación Dinámica: de 20 Bytes a Seis Dígitos
La etapa final es la parte ingeniosa heredada directamente de RFC 4226, llamada truncación dinámica. Su labor es escoger de forma determinista unos pocos bytes de los 20 del HMAC y convertirlos en un número decimal corto, de manera que ningún atacante pueda sesgarlo. El procedimiento es preciso y merece seguirse byte a byte.
- Mira el último byte de la salida del HMAC (el byte 19 para SHA-1). Toma sus cuatro bits bajos — su nibble bajo — calculando
ultimoByte & 0x0F. Esto da un valor de 0 a 15. Llámalo el desplazamiento (offset). - A partir de ese desplazamiento, lee cuatro bytes consecutivos de la salida del HMAC. Como el desplazamiento es como mucho 15 y lees cuatro bytes, el byte más alto que puedes tocar es el índice 18 — todavía dentro, con seguridad, de un array de 20 bytes. Por eso el desplazamiento se toma del último byte y se enmascara a un nibble: garantiza que la ventana de cuatro bytes siempre cabe.
- Ensambla esos cuatro bytes en un entero de 31 bits. El bit más alto se enmascara con
& 0x7FFFFFFFpara evitar cualquier confusión con el bit de signo en lenguajes de programación que tratan el bit inicial como negativo. El resultado es un entero positivo entre 0 y algo más de 2.100 millones. - Reduce ese entero al número de dígitos deseado con un módulo:
valor % 10^dígitos. Para el código estándar de seis dígitos eso esvalor % 1000000, dando un número de 0 a 999999. - Rellena por la izquierda el número con ceros hasta el total de dígitos. Un valor calculado de 42 se convierte en el código mostrado
000042. Este relleno importa: quitar los ceros iniciales produciría un código de longitud incorrecta que el servidor rechaza.
La razón de elegir el desplazamiento dinámicamente — en lugar de leer siempre, digamos, los cuatro primeros bytes — es repartir qué parte de la salida del HMAC determina el código. Asegura que cada byte del hash pueda influir en el resultado con distintas entradas, eliminando cualquier estructura fija que un atacante pudiera aprovechar. Toda la truncación es determinista: dado el mismo HMAC, siempre produce el mismo código, que es exactamente lo que permite que dos dispositivos independientes coincidan.
Qué Almacena el Servidor y Cómo Verifica
Un punto crucial y a menudo malentendido: el servidor no almacena una lista de códigos válidos, y no almacena el código que estás a punto de teclear. Almacena únicamente el secreto compartido y los parámetros — el algoritmo, el número de dígitos y el período. Cuando envías un código al iniciar sesión, el servidor realiza exactamente el mismo cálculo que acaba de hacer tu app: lee su propio reloj, calcula el contador actual, ejecuta HMAC con el secreto almacenado, aplica la truncación dinámica y compara el resultado con lo que tecleaste. Si coinciden, estás autenticado. El código en sí nunca se transmite del servidor al cliente ni se persiste; existe solo durante el instante de la comparación.
Este diseño tiene una consecuencia de seguridad importante. Como el secreto es simétrico, cualquiera que lea el almacén de secretos del servidor puede generar códigos válidos para siempre, y por eso los secretos TOTP deben cifrarse en reposo y tratarse con el mismo cuidado que los hashes de contraseña. También significa que TOTP, a diferencia de una llave de seguridad física, no resiste el phishing por sí solo: una página de inicio de sesión falsa y convincente puede capturar tus seis dígitos y reproducirlos ante el servidor real dentro de la ventana de 30 segundos. Ese compromiso, y cómo los métodos más nuevos lo abordan, es el tema de nuestra comparativa, TOTP frente a SMS y passkeys.
Deriva de Reloj y Ventanas de Validación
En el mundo real, los relojes nunca están perfectamente sincronizados. Tu teléfono puede ir unos segundos adelantado, el servidor unos segundos atrasado, o puedes tardar un momento en teclear el código y cruzar un límite de paso a mitad de escritura. Si el servidor solo aceptara el único código de su contador actual exacto, TOTP fallaría constantemente y exasperaría a los usuarios. RFC 6238 aborda esto recomendando una ventana de validación. En lugar de comprobar un contador, el servidor también comprueba los pasos inmediatamente adyacentes.
La práctica estándar es aceptar el paso actual más o menos uno — es decir, calcula los códigos de contador − 1, contador y contador + 1 y acepta tu entrada si coincide con cualquiera de los tres. Con un período de 30 segundos, esta tolerancia de ±1 da una ventana efectiva de aceptación de hasta 90 segundos, absorbiendo una deriva modesta y el retraso al teclear sin debilitar de forma apreciable la seguridad. El RFC señala el compromiso con claridad: cada paso adicional que el servidor acepta amplía la ventana que un atacante tiene para adivinar o reproducir un código, así que los servidores mantienen la ventana pequeña. Algunos servidores implementan la resincronización, recordando un desplazamiento persistente si el dispositivo de un usuario concreto va sistemáticamente un paso por delante o por detrás, de modo que un reloj crónicamente desviado aún pueda autenticar con fiabilidad. Los servidores bien construidos también imponen que cualquier código dado solo pueda usarse una vez dentro de su ventana, evitando que un atacante reproduzca de inmediato un código que haya observado.
Por Qué Ambas Partes Deben Coincidir en Cada Parámetro
El código que sale de TOTP es exquisitamente sensible a sus entradas. Cambia el algoritmo de hash, el número de dígitos o el período, y cada código cambia por completo. Por eso la URI de alta lleva algorithm, digits y period de forma explícita, y por eso la app y el servidor deben respetar ambos esos valores exactos. Si un servidor emite un código QR configurado para SHA-256 y 8 dígitos con un período de 60 segundos, pero la app asume en silencio los valores por defecto de SHA-1, 6 dígitos y 30 segundos, los dos calcularán códigos totalmente distintos y todo intento de inicio de sesión fallará — aunque el secreto compartido sea idéntico en ambos lados.
En la práctica, la gran mayoría de los servicios usan los valores clásicos por defecto: SHA-1, seis dígitos y un período de 30 segundos. Muchas apps autenticadoras populares históricamente ignoraron por completo los parámetros no predeterminados algorithm y digits, lo cual es una razón por la que esos valores por defecto han demostrado ser tan persistentes — un servicio que se aparta de ellos arriesga incompatibilidad con las apps que sus usuarios ya tienen instaladas. Cuando construyas o depures una integración TOTP, lo primero que hay que confirmar cuando los códigos no coinciden es que el algoritmo, el número de dígitos y el período son idénticos en ambos extremos, y lo segundo es que los relojes concuerdan. Estas dos comprobaciones resuelven la gran mayoría de los problemas de TOTP.
Todo Junto
La vida completa de un código de inicio de sesión es, por tanto, breve y del todo determinista. En el alta, se genera un secreto aleatorio, se codifica en base32 y se traslada a tu app mediante un código QR. En el momento del inicio de sesión, tanto tu app como el servidor toman la hora Unix actual, la dividen entre 30 y aplican el suelo para obtener un contador, formatean ese contador como ocho bytes big-endian, ejecutan HMAC-SHA-1 sobre esos bytes con clave del secreto compartido, aplican la truncación dinámica para extraer un número de 31 bits, lo reducen módulo un millón y lo rellenan con ceros hasta seis dígitos. Como las dos partes comparten el secreto y leen el mismo reloj, aterrizan en los mismos seis dígitos de forma independiente, y una pequeña ventana de validación perdona la inevitable deriva entre ellos. Ningún código secreto cruza jamás la red, y cada código caduca en segundos. Para ver estos pasos exactos calcularse en vivo sobre un secreto de tu elección, abre el generador TOTP y observa cómo el contador, el HMAC y el código truncado se actualizan en tiempo real.