PHP

password_hash en PHP: Cómo Hashear Contraseñas con bcrypt

Autorangel cruz
Actualizado
Publicado
Lectura10 min de lectura
password_hash en PHP: Cómo Hashear Contraseñas con bcrypt

password_hash() es la función nativa de PHP para hashear contraseñas de forma segura. Genera el hash usando bcrypt (o Argon2) con un salt aleatorio incluido dentro del propio hash, y luego se comprueba con password_verify(). Es la forma recomendada desde PHP 5.5: nunca uses md5() ni sha1() para contraseñas.

El uso mínimo y seguro es este:

// Al registrar: guardas el hash, nunca la contraseña en texto plano.
$hash = password_hash($password, PASSWORD_DEFAULT);
 
// Al iniciar sesión: comparas la contraseña contra el hash guardado.
if (password_verify($password, $hash)) {
    // La contraseña es correcta.
}

Con eso ya tienes un login seguro. El resto del artículo explica qué pasa por dentro y cómo afinarlo.

Por qué no usar md5() ni sha1()

Es común ver código viejo que hashea contraseñas con md5:

md5('password'); // 5f4dcc3b5aa765d61d8327deb882cf99

El problema es que md5 y sha1 son rápidos y sin salt: una GPU prueba miles de millones de combinaciones por segundo, y existen tablas (rainbow tables) que revierten esos hashes al instante. Para contraseñas necesitas justo lo contrario: un algoritmo lento y con salt, que es lo que te da password_hash().

Hashear no es encriptar

Es una confusión muy extendida, y la diferencia importa. Encriptar es reversible: existe una clave que devuelve el texto original. Hashear no lo es: de un hash no se recupera la contraseña por diseño.

Las contraseñas se hashean, no se encriptan. Si tu sistema puede mostrarle a un usuario su contraseña actual, está guardándola de forma reversible, y eso es un fallo de seguridad. Lo correcto es no poder recuperarla nunca y ofrecer un flujo de restablecimiento.

La firma de password_hash()

Según la documentación oficial, la función recibe la contraseña, el algoritmo y un array opcional de opciones:

password_hash(string $password, string|int|null $algo, array $options = []): string

Desde PHP 8.0 ya no devuelve false en caso de fallo: lanza un ValueError si el algoritmo no es válido, o un Error si el hashing falla por un motivo desconocido. El parámetro algo pasó a aceptar null en esa misma versión.

Qué algoritmo elegir

Aquí es donde la recomendación ha cambiado, aunque la función no.

Constante Algoritmo Cuándo usarlo
PASSWORD_DEFAULT Hoy bcrypt El valor por defecto sensato. Puede cambiar en futuras versiones de PHP
PASSWORD_BCRYPT bcrypt, siempre Cuando necesitas hashes de longitud fija y previsible
PASSWORD_ARGON2ID Argon2id La recomendación de OWASP para proyectos nuevos
PASSWORD_ARGON2I Argon2i Prácticamente superado por Argon2id

La hoja de recomendaciones de OWASP ordena las opciones así: Argon2id primero, scrypt si Argon2id no está disponible, bcrypt para sistemas heredados y PBKDF2 cuando hace falta cumplir FIPS-140. Es decir, bcrypt ya no es la primera opción según ese criterio, aunque sigue siendo perfectamente válido y es lo que PHP te da por defecto.

En la práctica, para PHP:

  • Proyecto nuevo con Argon2 disponible: usa PASSWORD_ARGON2ID.
  • Cualquier otro caso: PASSWORD_DEFAULT está bien y no es una decisión que debas agonizar. bcrypt con un cost adecuado sigue siendo resistente.

Un detalle de operación: PASSWORD_DEFAULT está diseñado para cambiar con las versiones de PHP, así que la longitud del hash puede crecer. Guarda la columna como VARCHAR(255), nunca ajustada a 60.

Anatomía de un hash bcrypt

Un hash de bcrypt siempre mide 60 caracteres y no es una cadena opaca: lleva dentro todo lo necesario para verificarlo después.

Un hash de bcrypt de 60 caracteres descompuesto en sus cuatro partes. Los primeros cuatro caracteres, $2y$, son el identificador del algoritmo. Los dos siguientes son el cost, aquí 12, que equivale a 4.096 iteraciones. Tras un separador vienen 22 caracteres de salt, resaltados, y los últimos 31 son el hash en sí. El salt viaja dentro del propio hash, por eso no hace falta guardarlo en una columna aparte.

Por eso no necesitas una columna aparte para el salt, que es la duda más habitual al empezar: el salt ya viaja dentro del hash. Cuando llamas a password_verify(), PHP lee de la propia cadena qué algoritmo y qué cost se usaron, extrae el salt y recalcula.

Si ves un hash que empieza por $2a$ o $2b$, es bcrypt generado por otra implementación o por una versión antigua. password_verify() los acepta igual.

Los de Argon2id son otra cosa: empiezan por $argon2id$v=19$m=65536,... y miden 97 caracteres con los parámetros por defecto, porque llevan dentro los tres costes además del salt. Ahí se ve por qué la columna tiene que ir holgada y no ajustada a los 60 de bcrypt.

El parámetro cost

El cost controla cuánto trabajo cuesta calcular el hash: es un exponente, así que cada punto duplica el tiempo. A mayor cost, más resistente a fuerza bruta.

$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

El valor por defecto de bcrypt era 10 y subió a 12 en PHP 8.4. Los valores admitidos van de 4 a 31; fuera de ese rango PHP lanza un ValueError con el mensaje Invalid bcrypt cost parameter specified.

La forma de elegirlo no es copiar un número de un tutorial, sino medirlo en el servidor donde va a correr:

$inicio = microtime(true);
password_hash('una contraseña de prueba', PASSWORD_BCRYPT, ['cost' => 12]);
echo microtime(true) - $inicio;

La referencia habitual es apuntar a unos 250 milisegundos: suficiente para que la fuerza bruta sea cara y poco suficiente para que un login no se note lento. Si tu servidor tarda 40 ms con cost 12, puedes subir a 14. Ten en cuenta que ese coste lo pagas en cada inicio de sesión, así que un cost demasiado alto convierte tu formulario de login en un vector de denegación de servicio.

No definas el salt a mano: la opción se marcó como obsoleta en PHP 7.0 y desde PHP 8.0 un salt explícito se ignora. password_hash() ya genera uno criptográficamente seguro por ti.

El límite de 72 bytes que trunca en silencio

Este es el detalle de bcrypt que más problemas causa y que casi ningún tutorial menciona: bcrypt solo tiene en cuenta los primeros 72 bytes de la contraseña. El resto se descarta, sin aviso, sin error y sin nota en los logs.

$a = str_repeat('a', 72);
$b = str_repeat('a', 72) . 'esto_da_igual';
 
password_verify($b, password_hash($a, PASSWORD_BCRYPT)); // true

Esas dos contraseñas son la misma para bcrypt. El límite viene de la propia expansión de clave de Blowfish, no de PHP, así que no es algo que se pueda configurar.

En la práctica rara vez importa, porque pocas personas usan contraseñas de más de 72 caracteres. Se vuelve un problema real en dos casos: cuando usas un gestor de contraseñas que genera cadenas larguísimas, y sobre todo cuando pre-hasheas la contraseña antes de pasarla a bcrypt.

Ese patrón (aplicar sha256 y luego bcrypt, normalmente para añadir un pepper) tiene dos trampas documentadas por OWASP. La primera es que la salida binaria de un hash puede contener bytes nulos, y ahí PHP sí te para en seco:

password_hash("con\0byte_nulo", PASSWORD_BCRYPT);
// ValueError: Bcrypt password must not contain null character

La segunda es el password shucking, un ataque que aprovecha justamente esa composición de algoritmos. Si necesitas un pepper, OWASP recomienda hacerlo con HMAC y guardar la clave fuera de la base de datos, no encadenar hashes a mano.

Argon2id no tiene este límite, que es otro argumento a su favor en proyectos nuevos.

Argon2id en PHP

Argon2id está disponible desde PHP 7.3 (Argon2i desde 7.2), siempre que PHP se haya compilado con soporte para Argon2. Se usa igual, cambiando la constante:

$hash = password_hash($password, PASSWORD_ARGON2ID);

Acepta tres opciones, cuyos valores por defecto en PHP son:

Opción Por defecto Qué controla
memory_cost 65536 (64 MiB) Memoria que consume el cálculo
time_cost 4 Número de iteraciones
threads 1 Hilos en paralelo
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536, // en KiB
    'time_cost'   => 4,
    'threads'     => 1,
]);

Los valores por defecto de PHP son más exigentes que el mínimo que pide OWASP, que es de 19 MiB de memoria, 2 iteraciones y 1 grado de paralelismo. O sea que si no tocas nada, vas bien.

La ventaja de Argon2 sobre bcrypt es que su coste no es solo de CPU sino también de memoria, lo que encarece mucho los ataques con GPU y hardware dedicado, que es exactamente de lo que te quieres defender.

Verificar y rehashear

Para comprobar una contraseña usas password_verify(), que lee el algoritmo y el cost del propio hash, recalcula y compara:

if (password_verify($password, $hashGuardado)) {
    // Acceso concedido.
}

Nunca compares hashes con == o ===. password_verify() usa una comparación de tiempo constante, que recorre siempre todos los bytes, para no filtrar información por el tiempo que tarda en responder.

Como el algoritmo recomendado y el cost cambian con las versiones de PHP, conviene rehashear cuando el usuario inicia sesión, que es el único momento en el que tienes la contraseña en claro:

if (password_verify($password, $hashGuardado)) {
    if (password_needs_rehash($hashGuardado, PASSWORD_DEFAULT)) {
        $nuevoHash = password_hash($password, PASSWORD_DEFAULT);
        // Guarda $nuevoHash en la base de datos.
    }
}

Así tus hashes se migran solos, sin pedirle a nadie que cambie su contraseña. Es el mecanismo que hace que la subida de cost de PHP 8.4 se aplique a tus usuarios existentes.

Para inspeccionar un hash sin verificarlo, password_get_info() te dice con qué se generó:

print_r(password_get_info($hash));
// ['algo' => '2y', 'algoName' => 'bcrypt', 'options' => ['cost' => 12]]

Es útil para auditar qué tienes en la base de datos antes de decidir una migración.

Resumen

  • Usa password_hash($password, PASSWORD_DEFAULT) y password_verify(). En proyectos nuevos con Argon2 disponible, PASSWORD_ARGON2ID.
  • Nunca uses md5 ni sha1, ni guardes la contraseña en texto plano. Hashear no es encriptar.
  • El salt ya viaja dentro del hash. No lo generes ni lo guardes aparte.
  • Columna VARCHAR(255), no 60: el algoritmo por defecto puede cambiar.
  • Mide el cost en tu servidor en vez de copiarlo. Referencia: ~250 ms.
  • bcrypt ignora todo lo que pase de 72 bytes. Si pre-hasheas, usa HMAC.
  • Rehashea con password_needs_rehash() en cada login.

La otra mitad de la seguridad de un proyecto PHP son las dependencias, y las dos cosas están juntas en la guía de PHP.

Preguntas frecuentes

¿Qué diferencia hay entre PASSWORD_DEFAULT y PASSWORD_BCRYPT?

PASSWORD_BCRYPT fuerza bcrypt siempre, y produce hashes de 60 caracteres que empiezan por $2y$. PASSWORD_DEFAULT usa el algoritmo que PHP considere recomendado en cada versión, que hoy es bcrypt pero puede cambiar. Por eso con PASSWORD_DEFAULT la columna debe ser VARCHAR(255): si el valor por defecto cambia a Argon2id, los hashes nuevos serán más largos.

¿Cuál es el cost por defecto de bcrypt en PHP?

12 desde PHP 8.4. Antes era 10. Los valores válidos van de 4 a 31, y cada punto duplica el tiempo de cálculo. Fuera de ese rango PHP lanza un ValueError.

¿Necesito una columna para el salt?

No. El salt lo genera password_hash() y va incluido dentro del hash, en los 22 caracteres que siguen al cost. password_verify() lo extrae de ahí. Desde PHP 8.0, si pasas un salt explícito en las opciones, se ignora.

¿Por qué mi hash mide 60 caracteres?

Porque bcrypt siempre produce esa longitud: 7 caracteres de cabecera ($2y$12$), 22 de salt y 31 del hash en sí. Los hashes de Argon2id son más largos y empiezan por $argon2id$, así que no dimensiones la columna a 60.

¿Qué pasa si mi contraseña tiene más de 72 caracteres?

bcrypt ignora todo lo que pase de 72 bytes, en silencio y sin error. Dos contraseñas que compartan los primeros 72 bytes son la misma para bcrypt. Argon2id no tiene ese límite.

¿Puedo usar md5 si le añado un salt?

No. El problema de md5 no es solo la falta de salt sino su velocidad: está diseñado para ser rápido, y eso es exactamente lo contrario de lo que necesita una contraseña. Una GPU prueba miles de millones de combinaciones por segundo contra un md5 salteado.

Fuentes