Hash de contraseñas con bcrypt y Argon2id, medido
Seguridad 2: rompe un hash sin sal con 20 palabras, mide bcrypt, Argon2id, scrypt y PBKDF2 por intento y reproduce el límite de 72 bytes de bcrypt.
Serie: curso de seguridad básica 2/6 (principiante). Esta clase es para ti si vas a escribir tu primer formulario de registro, o si heredaste la tabla de usuarios de un cliente y no sabes si las contraseñas están bien guardadas. Al terminar vas a guardar y verificar contraseñas correctamente en PHP y Python, vas a poder decir en milisegundos qué tan lento es "suficientemente lento" y vas a evitar los errores que aparecen una y otra vez en los reportes de filtraciones.
La clase 1 terminó con un número: un núcleo de laptop calcula unos 7.89 millones de SHA-256 por segundo. Esta clase muestra qué significa eso el día que se filtra tu base de datos. Si trabajas como freelancer, es muy común recibir un proyecto con contraseñas en MD5 o en texto plano "porque así lo hizo el desarrollador anterior". El GDPR/RGPD y las leyes de protección de datos de muchos países latinoamericanos piden, en términos generales, medidas de seguridad adecuadas, y guardar mal las contraseñas es difícil de defender después de un incidente (esto no es asesoría legal). En el OWASP Top 10 de 2021 el tema cae en Cryptographic Failures e Identification and Authentication Failures (A02 y A07).
Todo se ejecutó en una Mac Apple Silicon (M1 Pro) con PHP 8.5.6 (bcrypt y Argon2id vía password_hash, libsodium 1.0.22), Python 3.9.6 y OpenSSL 4.1.0. Cada "ataque" de esta clase corre contra usuarios y una lista de 20 palabras que inventamos, en nuestra propia máquina.
Cinco formas de guardar una contraseña, de la peor a la correcta
| Valor guardado | Ejemplo | Lo que obtiene quien roba la base |
|---|---|---|
| Texto plano | futbol123 |
Todas las contraseñas al instante, incluso para otros sitios donde se reutilizaron |
| Cifrado | AES de la contraseña | Todas, en cuanto se filtra también la clave (que suele vivir en el mismo servidor) |
| Hash rápido sin sal | sha256("futbol123") |
Las débiles en microsegundos; contraseñas iguales con hashes iguales |
| Hash rápido + sal | sha256(sal + pw) |
Las débiles igual en microsegundos, un usuario a la vez |
| Hash lento de contraseñas | bcrypt, Argon2id, scrypt, PBKDF2 | Cada intento cuesta decenas o cientos de milisegundos; las fuertes sobreviven |
Primero dos términos. Una sal (salt) es un valor aleatorio, distinto para cada usuario, que se hashea junto con la contraseña y se guarda a su lado. No es secreto. Su trabajo es que dos usuarios con la misma contraseña tengan hashes distintos, para obligar al atacante a atacar a cada uno por separado. Un hash lento de contraseñas (también llamado función de derivación de claves, KDF) repite su trabajo muchas veces, o necesita mucha memoria, a propósito, y tiene un costo que subes a medida que el hardware se vuelve más rápido.
Tu primer password_hash en 5 minutos
PHP trae la herramienta correcta desde la versión 5.5: password_hash() crea la sal, ejecuta bcrypt (o Argon2id) y devuelve una sola cadena que guardas en una sola columna.
<?php
$pw = 'tiger2026';
$h1 = password_hash($pw, PASSWORD_DEFAULT);
$h2 = password_hash($pw, PASSWORD_DEFAULT);
echo $h1, "\n", $h2, "\n";
var_dump($h1 === $h2); // misma contraseña, sal distinta
var_dump(password_hash($pw, PASSWORD_DEFAULT) === $h1); // el error de principiante
var_dump(password_verify($pw, $h1), password_verify('tiger2025', $h1));
print_r(password_get_info($h1));
$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq
$2y$12$29U5HBehXh1Llpb/miK5W.itq90n0dJTPPg7FSeA77my08szzXrES
bool(false)
bool(false)
bool(true)
bool(false)
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 12
)
)
Cómo se lee la cadena: $2y$ es bcrypt, 12 es el costo (2¹² rondas), los 22 caracteres siguientes son la sal y el resto es el hash. Todo lo necesario para verificar está adentro, y por eso importa la tercera línea: nunca hasheas el intento de login para comparar cadenas. Llamas a password_verify(), que lee la sal y el costo del valor guardado.
Demo local: SHA-256 sin sal contra una lista de 20 palabras
Esta es la forma vieja y mala, atacada como lo haría un atacante real. Los usuarios y la lista son inventados; nada sale de la máquina.
# Demo solo local: usuarios inventados, lista inventada de 20 palabras.
import hashlib, time
users = {'maria': 'futbol123', 'jose': 'teamo2026', 'lucia': 'futbol123', 'diego': 'V7#qLm2!pZ9x'}
words = ("password 123456 qwerty contrasena futbol futbol123 teamo teamo2026 hola123 mexico2026 "
"argentina colombia amor princesa admin welcome letmein dragon monkey verano").split()
leak = {u: hashlib.sha256(p.encode()).hexdigest() for u, p in users.items()} # bad old storage
for u, h in leak.items():
print(f"{u:7s} {h[:20]}...")
t = time.perf_counter_ns()
table = {hashlib.sha256(w.encode()).hexdigest(): w for w in words} # 20 hashes, once
for u, h in leak.items():
print(f"{u:7s} -> {table.get(h, '(not in list)')}")
print(f"{len(words)} words, {len(leak)} users, {(time.perf_counter_ns() - t)/1000:.0f} µs")
maria 5a81a27db0ca7c4bab65...
jose 24e6eccb3de36a9717fa...
lucia 5a81a27db0ca7c4bab65...
diego 3655812bce4e006ad2a4...
maria -> futbol123
jose -> teamo2026
lucia -> futbol123
diego -> (not in list)
20 words, 4 users, 14 µs
Dos cosas para notar. María y Lucía tienen el mismo hash, así que el atacante supo que compartían contraseña antes de romper nada. Y la lista se hasheó una sola vez y se reutilizó para todos. Eso es lo que hace una tabla precalculada, a una escala mucho mayor.
Al agregar una sal aleatoria de 16 bytes por usuario, el duplicado desapareció (los cuatro hashes pasaron a ser distintos), pero el atacante simplemente recorre la lista una vez por usuario: 40 llamadas a SHA-256, 26 µs, las mismas tres contraseñas encontradas. La sal es necesaria, pero no alcanza.
Ahora los mismos usuarios guardados con password_hash() (bcrypt, costo 12) y atacados con password_verify() para cada palabra:
maria $2y$12$dGz1DaoQ1SG5BLUVkyXoauCoruv8Rs7/JXyD9xkoR1.H8Os5ug24S
jose $2y$12$4dmOfPHoIbgGaGC8fjkpYu1CcQFKyo4AMLpJVpxsoNnDOA8C94s3i
lucia $2y$12$d2Zfs2wpzj1Mmj8.J8T.d.R0/yj.H5ae3jNp7obpPsrWgNm.2kb4e
diego $2y$12$9oOKDcsLcqtCtEBh8BkzbuHXW3q9vjCr6NVf/MD.Qvjic6Pk3Nd.C
maria -> futbol123
jose -> teamo2026
lucia -> futbol123
diego -> (not in list)
40 password_verify calls, 9.4 s
Seamos honestos con lo que pasó: bcrypt no salvó a futbol123. Una contraseña que está entre las primeras 20 de cualquier lista cae sea cual sea el hash. Lo que cambió bcrypt fue el precio: 9.4 segundos en lugar de 26 microsegundos, unas 360,000 veces más. Contra una lista real de millones de entradas, ese factor es la diferencia entre minutos y años. La contraseña aleatoria de 12 caracteres de Diego no apareció en ninguno de los dos ataques.
Medido: el costo de un intento
Cada algoritmo se cronometró en un núcleo. Las filas de PHP son la mediana de 11 ejecuciones tras un calentamiento (101 para SHA-256). La fila de scrypt y una de PBKDF2 usan openssl kdf, restando unos 6 ms de arranque del proceso.
El mismo PBKDF2 (600,000 iteraciones de HMAC-SHA256) tardó 794 ms con hash_pbkdf2() de PHP, 178 ms con hashlib.pbkdf2_hmac() de Python y 66 ms en OpenSSL. Tu login paga el precio de la implementación lenta; el atacante usa la rápida. Elige el número de iteraciones a partir del dato más rápido, no de lo que sientes en tu servidor.
Los mismos datos como "tiempo para probar N intentos en un núcleo":
| Almacenamiento (1 núcleo, M1 Pro) | Intentos/s | Lista de 20 | Lista de 1 millón | Todas de 8 minúsculas (26⁸) |
|---|---|---|---|---|
| SHA-256 (openssl) | 7,890,000 | 2.5 µs | 0.13 s | 7.4 horas |
| PBKDF2-SHA256, 600k (openssl) | 15.2 | 1.3 s | 18.3 horas | 437 años |
| Argon2id, 64 MiB, t=4 | 6.6 | 3.0 s | 1.8 días | 1,005 años |
| bcrypt, costo 12 | 4.2 | 4.7 s | 2.7 días | 1,567 años |
| scrypt, N=2¹⁷, r=8 | 3.3 | 6.0 s | 3.5 días | 1,993 años |
Los atacantes reales usan GPUs y muchas máquinas, así que divide cada celda por un factor grande (no lo medimos aquí). Lo que importa son las proporciones entre filas. Argon2id y scrypt suman una defensa que la tabla no muestra: cada intento necesita sus propios 64 MiB o 128 MiB de memoria, y eso hace que las GPUs rindan mucho menos contra ellos.
Subir el costo al iniciar sesión: password_needs_rehash
PHP 8.4 subió el costo por defecto de bcrypt de 10 a 12, y dentro de unos años vas a querer subir el tuyo otra vez. No puedes rehashear offline (no tienes las contraseñas), pero sí tienes la contraseña en texto plano durante un instante en cada login exitoso:
<?php
$users = ['alice' => password_hash('sunshine-river-42', PASSWORD_BCRYPT, ['cost' => 10])];
const DUMMY = '$2y$12$ml76qZwQkp2tlUE0cq8SROecy9eBO0VCT.qA6bCwVIdxwMoL.RBMq';
function login(array &$users, string $name, string $pw): bool {
$hash = $users[$name] ?? null;
if ($hash === null) { // usuario inexistente: gasta el mismo tiempo igual
password_verify($pw, DUMMY);
return false;
}
if (!password_verify($pw, $hash)) return false;
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$users[$name] = password_hash($pw, PASSWORD_DEFAULT);
echo "rehashed: ", substr($hash, 0, 7), " -> ", substr($users[$name], 0, 7), "\n";
}
return true;
}
foreach ([['alice', 'wrong'], ['alice', 'sunshine-river-42'], ['alice', 'sunshine-river-42'],
['mallory', 'x']] as [$u, $p]) {
$t = hrtime(true);
$ok = login($users, $u, $p);
printf("%-8s %-18s %-5s %6.1f ms\n", $u, $p, $ok ? 'OK' : 'FAIL', (hrtime(true) - $t) / 1e6);
}
alice wrong FAIL 59.6 ms
rehashed: $2y$10$ -> $2y$12$
alice sunshine-river-42 OK 299.0 ms
alice sunshine-river-42 OK 240.6 ms
mallory x FAIL 242.2 ms
Los tiempos muestran un detalle sutil. El hash ficticio para usuarios inexistentes tiene costo 12, pero el hash viejo de alice tenía costo 10, así que "contraseña incorrecta" (59.6 ms) y "usuario inexistente" (242.2 ms) se podían distinguir hasta que su hash se actualizó. Mantén el hash ficticio con el mismo costo que tus hashes reales. Las fugas por tiempo tienen su propia clase (clase 6).
El límite de 72 bytes de bcrypt, demostrado
bcrypt solo lee los primeros 72 bytes de la contraseña. PHP 8.5.6 todavía lo hace en silencio. Lo probamos con una frase larga (una frase famosa está en todas las listas, así que no la uses de verdad):
<?php
$a = 'en un lugar de la mancha, de cuyo nombre no quiero acordarme, no ha mucho';
printf("%d chars, %d bytes\n", mb_strlen($a), strlen($a));
$h = password_hash($a . ' A', PASSWORD_BCRYPT);
var_dump(password_verify($a . ' B', $h)); // bcrypt: solo cuentan los primeros 72 bytes
var_dump(password_verify(substr($a, 0, 72), $h));
$h = password_hash($a . ' A', PASSWORD_ARGON2ID);
var_dump(password_verify($a . ' B', $h)); // Argon2id: cuenta toda la entrada
73 chars, 73 bytes
bool(true)
bool(true)
bool(false)
Se aceptó un final distinto, y también los primeros 72 bytes solos. Sin acentos, 72 bytes son 72 caracteres; pero en UTF-8 cada ñ, á o é ocupa 2 bytes, así que una frase en español con muchos acentos llega al límite antes.
Una trampa relacionada: bcrypt en PHP 8.5.6 rechaza bytes NUL. El arreglo común de "prehashear con SHA-256 en binario" falla cerca del 12% de las veces, porque 32 bytes aleatorios contienen un \0 con probabilidad 1 − (255/256)³² ≈ 11.8%:
first candidate with NUL in raw sha256: user-pass-2 (byte 12)
ValueError: Bcrypt password must not contain null character
Pimienta: un secreto fuera de la base de datos
La pimienta (pepper) es un secreto del servidor que se mezcla con cada contraseña antes de hashearla. A diferencia de la sal, no se guarda en la base de datos sino en una variable de entorno, un gestor de secretos o un archivo que el servidor de base de datos no puede leer. Si solo se filtra la base, el atacante no puede probar ni un intento. HMAC más Base64 también resuelve las dos trampas de bcrypt de arriba: el resultado son 44 caracteres ASCII, sin NUL y por debajo de 72 bytes.
<?php
$pepper = getenv('PW_PEPPER') ?: 'demo-only-pepper-change-me-32byt';
function prep(string $pw, string $pepper): string {
return base64_encode(hash_hmac('sha256', $pw, $pepper, true)); // 44 ASCII chars, no NUL
}
$a = 'en un lugar de la mancha, de cuyo nombre no quiero acordarme, no ha mucho';
$h = password_hash(prep($a . ' A', $pepper), PASSWORD_DEFAULT);
var_dump(password_verify(prep($a . ' A', $pepper), $h));
var_dump(password_verify(prep($a . ' B', $pepper), $h)); // difiere después del byte 72: rechazado
var_dump(password_verify(prep($a . ' A', 'stolen-db-no-pepper'), $h));
bool(true)
bool(false)
bool(false)
El costo de la pimienta es operativo: si la pierdes nadie puede iniciar sesión, y cambiarla implica esperar a que todos vuelvan a entrar. Muchos equipos la omiten y usan Argon2id, que no tiene límite de 72 bytes. Las dos opciones son razonables; lo que no es razonable es una pimienta commiteada en el repositorio.
Python: no hay bcrypt en la biblioteca estándar
La biblioteca estándar de Python 3.9 no trae bcrypt ni Argon2. En esta Mac tampoco trae scrypt, porque su hashlib está compilado sobre LibreSSL 2.8.3:
AttributeError: module 'hashlib' has no attribute 'scrypt'
ModuleNotFoundError: No module named 'bcrypt'
hashlib.pbkdf2_hmac siempre está, y con 600,000 iteraciones coincide con la guía actual de OWASP para PBKDF2-HMAC-SHA256. Guarda el algoritmo, las iteraciones y la sal junto al hash, igual que PHP:
import base64, hashlib, hmac, secrets
ITER = 600_000
def hash_password(pw: str) -> str:
salt = secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), salt, ITER)
b = lambda x: base64.b64encode(x).decode()
return f"pbkdf2_sha256${ITER}${b(salt)}${b(dk)}"
def verify_password(pw: str, stored: str) -> bool:
algo, it, salt, dk = stored.split("$")
test = hashlib.pbkdf2_hmac("sha256", pw.encode("utf-8"), base64.b64decode(salt), int(it))
return hmac.compare_digest(test, base64.b64decode(dk))
s = hash_password("contraseña-larga-2026")
print(s)
print(verify_password("contraseña-larga-2026", s), verify_password("contrasena-larga-2026", s))
pbkdf2_sha256$600000$9iQkeVuu9z1oav7Una3Ifw==$0AZ4UOSjvnoCxtK+Oty+mzAK/7hHMjpEBs16Rw88vjY=
True False
Fíjate en el segundo False: contrasena sin ñ es otra contraseña. En un proyecto real agregarías un paquete mantenido de argon2 o bcrypt (aquí no se instaló nada). Frameworks como Django ya guardan contraseñas con esta forma algoritmo$iteraciones$sal$hash.
Errores comunes: qué pasa y cómo arreglarlo
1. Hashear el intento de login y comparar cadenas. Qué pasa: password_hash($pw) === $stored siempre es false, porque cada llamada usa una sal nueva (lo vimos arriba). Entonces alguien lo "arregla" quitando la sal. Arreglo: password_verify().
2. Comparar hashes con == en PHP.
string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true) // md5('240610708') == md5('QNKCDZO')
bool(false) // ===
bool(false) // hash_equals()
Las dos cadenas parecen el número 0 × 10^…, así que == las compara como números, incluso en PHP 8.5. Arreglo: password_verify() para contraseñas y hash_equals() para cualquier otra comparación de secretos.
3. Una columna demasiado corta. Qué pasa: las cadenas bcrypt tienen 60 caracteres, las de Argon2id tuvieron 97 en nuestra prueba, y PASSWORD_DEFAULT puede cambiar en versiones futuras. Un CHAR(60) trunca en silencio un hash futuro. Arreglo: VARCHAR(255), como recomienda el manual de PHP.
4. Recortar, pasar a minúsculas o limitar la contraseña. Qué pasa: un trim() o strtolower() antes de hashear debilita las contraseñas sin que nadie lo note, y un máximo de 16 caracteres bloquea las frases largas. Arreglo: toma los bytes tal como se escribieron, permite al menos 64 caracteres (la guía NIST SP 800-63B) y, si normalizas Unicode, hazlo siempre en NFC, para que una ñ escrita en un teclado distinto no deje a alguien afuera.
5. Mensajes distintos para "usuario inexistente" y "contraseña incorrecta". Qué pasa: los atacantes descubren qué correos están registrados. Arreglo: un solo mensaje, y una verificación ficticia con el mismo costo, como en el código de login de arriba.
6. Sin límite de intentos de login. Qué pasa: un hash lento protege una base filtrada, no tu formulario en vivo. Arreglo: límites por cuenta y por IP, más una revisión de contraseñas filtradas al registrarse. Y ojo: un hash lento también gasta CPU de tu servidor; con 237 ms por login, un núcleo atiende unos cuatro inicios de sesión por segundo.
Cómo elegir algoritmo y costo
| Situación | Elección | Parámetros iniciales (revisa la guía actual de OWASP) |
|---|---|---|
| Proyecto PHP nuevo | PASSWORD_ARGON2ID o PASSWORD_DEFAULT (bcrypt) |
Argon2id m=19 MiB, t=2, p=1 o más; bcrypt costo ≥ 10, por defecto 12 en PHP |
| Frases largas, mucho acento | Argon2id, o bcrypt con prehash HMAC + Base64 | igual que arriba |
| Python solo con biblioteca estándar | hashlib.pbkdf2_hmac |
SHA-256, 600,000 iteraciones, sal de 16 bytes |
| Requisito FIPS o de cumplimiento | PBKDF2-HMAC-SHA256 | 600,000 iteraciones o más |
| Tabla heredada con MD5/SHA-1 | Envolver ya con password_hash(md5_viejo), reemplazar en el próximo login |
marca las filas para saber en qué forma está cada una |
Después ajusta el costo en el hardware de producción: apunta a algo entre 100 y 500 ms por hash, medido en hora pico.
Límites honestos y notas de versión
- Ejecutado con PHP 8.5.6, Python 3.9.6 (LibreSSL 2.8.3) y OpenSSL 4.1.0.
password_hash()llegó en PHP 5.5, Argon2i en 7.2 y Argon2id en 7.3. El costo por defecto de bcrypt pasó de 10 a 12 en PHP 8.4; comprobamos 12 en 8.5.6. - El
ValueErrorpor bytes NUL y el truncado silencioso a 72 bytes se comprobaron solo en PHP 8.5.6. Las versiones viejas de PHP se comportan distinto con bytes NUL (no ejecutado aquí). - Todas las velocidades son de un núcleo M1 Pro. Los números de un atacante con GPU son mucho mayores para hashes rápidos y bastante menos dramáticos para los que usan mucha memoria; no corrimos una GPU.
- Las recomendaciones de parámetros de OWASP cambian; los números de la tabla eran los del OWASP Password Storage Cheat Sheet al momento de escribir. Revísalos antes de elegir.
- Todos los ataques usaron usuarios inventados y una lista inventada de 20 palabras en una máquina local. Las listas reales tienen millones de entradas.
Ejercicios
1. La tabla de usuarios de un cliente tiene 10,000 filas guardadas como sha256(password) sin sal. Sin forzar un cambio de contraseña, ¿cómo las proteges hoy?
2. Con los 236.7 ms medidos por hash bcrypt de costo 12, ¿cuántos logins por segundo atiende un núcleo, y qué sugiere eso para un endpoint de login sin límite de intentos?
3. Una usuaria elige la frase ñandú, piñata, señal, año, niño, montaña, mañana, España, cañón, otoño (70 caracteres). ¿bcrypt ignora algo? ¿Desde dónde?
Respuestas
1. Envuelve el hash viejo: guarda password_hash(sha256_viejo_en_hex, PASSWORD_DEFAULT) y marca la fila como "envuelta". En el login verifica con password_verify(hash('sha256', $pw), $stored); si tiene éxito, guarda un password_hash($pw) nuevo y quita la marca. Todas las filas quedan protegidas de inmediato y pasan a la forma normal a medida que los usuarios entran.
2. Unos 1000 / 236.7 ≈ 4.2 por segundo. Un atacante puede enviar muchas más peticiones y agotar tu CPU, así que el endpoint de login necesita límites aunque el hash sea lento.
3. Sí. La frase tiene 70 caracteres pero 82 bytes, porque cada ñ y cada vocal con tilde ocupan 2. El byte 72 cae en medio de la ó de cañón: bcrypt ve 61 caracteres completos (hasta cañ) más el primer byte de la ó, y todo lo demás (n, otoño y el resto de la ó) se ignora. En PHP 8.5.6 comprobamos que esos primeros 72 bytes pasan password_verify. Usa Argon2id, o un prehash con HMAC + Base64.
Lo que sigue
- Anterior: curso de seguridad 1/6: hash, cifrado y codificación
- Siguiente: curso de seguridad 3/6: números aleatorios seguros y tokens
- Más adelante: trampas de firma en JWT HS256, AES-GCM bien hecho, ataques de tiempo y comparación en tiempo constante
- Relacionado: seguridad en PHP: XSS, CSRF y sesiones (en inglés), inyección SQL con PDO y SQLite (en inglés), auditoría de SQLi y XSS en PHP heredado
- Todas las series: la hoja de ruta para aprender a programar
Preguntas frecuentes
¿SHA-256 con sal es suficiente para contraseñas?
No. La sal anula las tablas precalculadas y hace que contraseñas iguales tengan hashes distintos, pero SHA-256 sigue siendo rápido: PHP calculó unos 2.2 millones de SHA-256 con sal por segundo en un núcleo M1 Pro. Nuestra lista de 20 palabras encontró todas las contraseñas débiles en 26 microsegundos. Necesitas un hash lento a propósito como bcrypt, Argon2id, scrypt o PBKDF2 con muchas iteraciones.
¿bcrypt de verdad ignora todo lo que va después de 72 bytes?
Sí. En PHP 8.5.6, hasheamos con bcrypt una frase de 73 bytes más ' A', y password_verify() también aceptó la misma frase más ' B', y solo sus primeros 72 bytes. Una ñ o una vocal con tilde ocupan 2 bytes en UTF-8, así que el límite llega antes de 72 caracteres. Argon2id no tiene ese límite; con bcrypt, aplica antes HMAC y Base64.
¿Cómo paso a usuarios antiguos a un costo mayor o a Argon2id?
En el inicio de sesión, justo después de que password_verify() tenga éxito, llama a password_needs_rehash() con tu algoritmo y opciones actuales. Si devuelve true, vuelve a hashear la contraseña en texto plano que acabas de verificar y actualiza la fila. En nuestra prueba un hash de costo 10 pasó a costo 12 en el primer inicio exitoso. Quien nunca inicia sesión conserva el hash viejo.