En resumen: los tokens seguros se generan en PHP con random_bytes(), por ejemplo 32 bytes que se convierten en texto con bin2hex() o Base64-URL. Para números aleatorios dentro de un rango existe random_int() y, para códigos de un conjunto fijo de caracteres, desde PHP 8.3, Random\Randomizer::getBytesFromString(). Los tokens se guardan como hash, se limita su validez, se usan una sola vez y se comparan con hash_equals().
Un enlace demasiado fácil de adivinar
«¿Ha olvidado su contraseña?». Un clic, y el sistema envía un enlace con un código largo. Quien conozca el enlace puede establecer la contraseña. Justo por eso, ese código tiene que ser impredecible. Si no lo es, un atacante no necesita ni el buzón ni la contraseña antigua, solo suficientes intentos.
En las auditorías de aplicaciones PHP se encuentran sorprendentemente a menudo tokens construidos a partir de la hora, el ID de usuario o un generador de números aleatorios simple. Parecen largos y aleatorios, pero no lo son. Lo mismo vale para claves de API, enlaces de invitación, enlaces para compartir documentos, códigos de confirmación y tokens CSRF.
El riesgo es tangible: un enlace de restablecimiento predecible permite apoderarse de cualquier cuenta cuya dirección de correo se conozca, sin que el titular se dé cuenta hasta que él mismo ya no puede iniciar sesión.
PHP ofrece desde la versión 7.0 una solución sencilla y segura. Solo hay que usarla de forma consecuente.
random_bytes en PHP: de dónde vienen los valores aleatorios seguros
Según el manual de PHP, random_bytes() entrega bytes aleatorios criptográficamente seguros, adecuados también para secretos de larga vida como las claves. La función usa la fuente de aleatoriedad del sistema operativo, en Linux por ejemplo getrandom() y en Windows la interfaz CNG. Si no encuentra una fuente adecuada, lanza desde PHP 8.2 una Random\RandomException.
La tabla muestra qué funciones sirven para secretos:
| Función | ¿Adecuada para tokens? | Observación |
|---|---|---|
random_bytes() |
sí | Bytes seguros, desde PHP 7.0 |
random_int() |
sí | Número seguro dentro de un rango, desde PHP 7.0 |
Random\Randomizer con motor Secure |
sí | Según el manual, Secure es el motor predeterminado |
rand(), mt_rand() |
no | Predecibles, solo para fines no relevantes para la seguridad |
uniqid() |
no | Basada en el tiempo, pensada para la unicidad |
md5(time()), sha1($id) |
no | Entrada adivinable, el hash no cambia eso |
str_shuffle(), array_rand() |
no | No usan un generador seguro |
Generar tokens: longitud y codificación
32 bytes aleatorios equivalen a 256 bits y son más que suficientes para casi cualquier fin. Para transmitirlo en enlaces o cabeceras, el token debe convertirse en texto. Son habituales dos variantes:
<?php
declare(strict_types=1);
// Hex: 64 caracteres, solo 0-9 y a-f
$tokenHex = bin2hex(random_bytes(32));
// Base64-URL: 43 caracteres, seguro en URL sin más codificación
$tokenUrl = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Número aleatorio dentro de un rango, por ejemplo para una selección
$index = random_int(0, 9);
// Desde PHP 8.3: código numérico de seis cifras, p. ej. para una confirmación por SMS
$randomizer = new \Random\Randomizer();
$code = $randomizer->getBytesFromString('0123456789', 6);
Los códigos numéricos cortos solo son defendibles si el número de intentos está estrictamente limitado y el código caduca a los pocos minutos. Para los enlaces que se envían por correo, use siempre el token largo.
Para las claves de API que usan de forma duradera socios o sistemas propios, han dado buen resultado dos complementos. Un prefijo corto y no secreto antes de la parte aleatoria, por ejemplo para el entorno o la finalidad, facilita reconocerla en los logs y en hallazgos en repositorios de código. Y la clave se muestra una sola vez al crearla; después, el sistema solo conoce su hash. Si se pierde una clave, se sustituye, no se recupera.
Cuidado con los tokens de fabricación propia que contienen información, por ejemplo ID de usuario y hora de caducidad en Base64. Codificar no es proteger: cualquiera puede leer y modificar esos tokens. Si un token debe llevar datos, tiene que estar firmado con una clave secreta, por ejemplo con hash_hmac('sha256', ...), y la firma se comprueba con hash_equals(). Para casos más complejos, son preferibles las bibliotecas contrastadas a las construcciones propias.
Guardar, comprobar y hacer caducar tokens
Un token generado de forma segura es solo la mitad del trabajo. Al menos igual de importante es cómo lo trata la aplicación. Un procedimiento acreditado para enlaces de restablecimiento y de invitación:
- Guardar solo el hash: en la base de datos consta
hash('sha256', $token); el token en sí solo llega al usuario mediante el enlace. - Limitar la validez: guardar también la hora de caducidad y comprobarla al canjear.
- Usar una sola vez: tras un uso correcto, borrar el registro o marcarlo como consumido.
- Invalidar ante cambios: tras un cambio de contraseña, borrar todos los tokens de restablecimiento abiertos de la cuenta.
- Comparar en tiempo constante: donde un token se compare directamente con un valor guardado, usar
hash_equals(), con el valor guardado como primer parámetro.
<?php
// Generar
$token = bin2hex(random_bytes(32));
$eintrag = [
'konto_id' => $kontoId,
'token_hash' => hash('sha256', $token),
'gueltig_bis'=> (new DateTimeImmutable('+30 minutes'))->format('Y-m-d H:i:s'),
];
// enviar $token solo en el enlace, guardar $eintrag
// Canjear
$gesucht = hash('sha256', (string) ($_GET['token'] ?? ''));
// buscar el registro mediante una consulta preparada por token_hash,
// comprobar la caducidad y borrar después el registro de inmediato
Limite además el número de peticiones de restablecimiento por cuenta y período, para que la función no pueda usarse indebidamente para el envío masivo de correos. La respuesta a una petición de restablecimiento tampoco debería revelar si la dirección indicada tiene siquiera una cuenta.
Un hash rápido como SHA-256 basta aquí, porque el token en sí ya es largo y aleatorio. Para las contraseñas que eligen las personas esto no vale; allí hacen falta procedimientos lentos, como explica Guardar contraseñas en PHP con seguridad.
Los tokens no pertenecen a los archivos de log. Compruebe si su servidor web registra URL completas con parámetros de consulta y si los informes de errores envían datos de la petición.
Tras un incidente: renovar tokens y claves
Quien pudo ejecutar código en un servidor solía tener también acceso a tokens guardados, claves de API y datos de sesión. Si tras PHP2Shell o cualquier otro incidente existe la sospecha de una intrusión, debería por ello renovar no solo las contraseñas, sino también los tokens y claves de larga duración: accesos de API, claves de firma de cookies o enlaces, accesos a servicios de pago y de envío.
Las sesiones son un caso especial: PHP genera por sí mismo los ID de sesión. Cómo proteger sus ajustes y cerrar las sesiones tras un incidente lo describe la guía Proteger las sesiones en PHP. Para los tokens de formulario contra peticiones falsificadas, véase Protección CSRF en aplicaciones PHP. Otras herramientas integradas las presenta Funciones de seguridad integradas de PHP.
¿Cómo ayuda PHP SOS en concreto?
- Encontramos en su aplicación todos los puntos en los que se generan tokens, códigos y claves y sustituimos las fuentes inseguras.
- Comprobamos el almacenamiento, la caducidad y el uso único de los enlaces de restablecimiento, de invitación y de acceso compartido.
- Nos aseguramos de que los tokens no acaben en logs, informes de errores ni herramientas de análisis.
- Tras un incidente, planificamos con usted la sustitución ordenada de tokens y claves.
La revisión de software PHP propio la ofrecemos como auditoría de seguridad y prueba de penetración.
Preguntas frecuentes
¿Por qué no es adecuado mt_rand para tokens?
mt_rand y rand están pensadas para estadística y juegos, no para secretos. En ciertas circunstancias sus salidas pueden predecirse. Para todo lo que un atacante no deba poder adivinar, use random_bytes o random_int.
¿Qué longitud debería tener un token?
Para enlaces de restablecimiento de contraseña, invitaciones o claves de API, 32 bytes aleatorios son una elección sólida; como texto hexadecimal, 64 caracteres. 16 bytes, es decir, 128 bits, son un límite inferior habitual.
¿Es uniqid un token seguro?
No. uniqid se basa en la hora actual y está pensada para la unicidad, no para la confidencialidad. También las combinaciones como md5 de la hora o del ID de usuario son predecibles.
¿Debo cifrar los tokens de restablecimiento en la base de datos?
Guarde en lugar del token un hash de este, por ejemplo con SHA-256. Quien lea la base de datos no podrá construir con él enlaces válidos. Como el token en sí ya es largo y aleatorio, no hace falta un procedimiento lento como el de las contraseñas.
¿Qué ocurre si el sistema no encuentra una fuente de aleatoriedad?
random_bytes y random_int lanzan desde PHP 8.2 una Random\RandomException. Capture este caso y aborte la operación, en lugar de recurrir a una fuente más débil.