Guardar contraseñas en texto plano o con MD5/SHA1 es un error de seguridad grave. PHP tiene desde la versión 5.5 las funciones password_hash() y password_verify(), que implementan automáticamente los algoritmos seguros, el salting aleatorio y la gestión de coste. Nunca deberías necesitar implementar este mecanismo a mano.
password_hash() y password_verify()
<?php
// Registro: hashear la contraseña antes de guardarla
$passwordPlano = 'MiContraseña2026!';
$hash = password_hash($passwordPlano, PASSWORD_DEFAULT);
// $hash tiene un aspecto similar a:
// $2y$12$JnVbL5O2QZ8K1Aq3mXrv6u.Wv7gK9N8hE0YdT2pL6MqA3RtSxB7a
// Login: verificar la contraseña introducida contra el hash guardado
if (password_verify($passwordPlano, $hash)) {
echo 'Contraseña correcta';
} else {
echo 'Contraseña incorrecta';
}
// IMPORTANTE: cada llamada a password_hash() genera un hash diferente
// porque el salt es aleatorio, pero password_verify() siempre funciona
$hash2 = password_hash($passwordPlano, PASSWORD_DEFAULT);
var_dump($hash === $hash2); // false hashes distintos, misma contraseña
var_dump(password_verify($passwordPlano, $hash2)); // true
?>
PASSWORD_BCRYPT vs PASSWORD_ARGON2ID
<?php
// BCrypt: disponible desde PHP 5.5, siempre presente
$hashBcrypt = password_hash('clave', PASSWORD_BCRYPT, [
'cost' => 12, // valor por defecto: 10; rango 431
]);
echo password_get_info($hashBcrypt)['algoName']; // bcrypt
// Argon2id: disponible desde PHP 7.3 (con libargon2)
// Más resistente a ataques de GPU que BCrypt
if (defined('PASSWORD_ARGON2ID')) {
$hashArgon = password_hash('clave', PASSWORD_ARGON2ID, [
'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST, // 65536 KB
'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST, // 4 iteraciones
'threads' => PASSWORD_ARGON2_DEFAULT_THREADS, // 1
]);
echo password_get_info($hashArgon)['algoName']; // argon2id
}
?>
Migrar hashes obsoletos con password_needs_rehash()
<?php
function login(string $email, string $passwordIntroducido, PDO $pdo): bool
{
$stmt = $pdo->prepare('SELECT id, password_hash FROM usuarios WHERE email = ?');
$stmt->execute([$email]);
$usuario = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$usuario || !password_verify($passwordIntroducido, $usuario['password_hash'])) {
return false;
}
// Si el algoritmo o el coste han cambiado, re-hashear en el login
if (password_needs_rehash($usuario['password_hash'], PASSWORD_DEFAULT)) {
$nuevoHash = password_hash($passwordIntroducido, PASSWORD_DEFAULT);
$update = $pdo->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?');
$update->execute([$nuevoHash, $usuario['id']]);
}
return true;
}
?>
Registro completo: hash + validación de política
<?php
function registrarUsuario(string $email, string $password, PDO $pdo): int
{
// 1. Política de contraseña
if (strlen($password) < 10) {
throw new InvalidArgumentException('La contraseña debe tener al menos 10 caracteres');
}
if (!preg_match('/[A-Z]/', $password)) {
throw new InvalidArgumentException('Debe incluir al menos una mayúscula');
}
if (!preg_match('/[0-9]/', $password)) {
throw new InvalidArgumentException('Debe incluir al menos un número');
}
// 2. Hashear
$hash = password_hash($password, PASSWORD_DEFAULT);
// 3. Guardar (NUNCA guardes $password, solo $hash)
$stmt = $pdo->prepare('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)');
$stmt->execute([$email, $hash]);
return (int) $pdo->lastInsertId();
}
?>
Generar tokens seguros con random_bytes()
<?php
// Token de recuperación de contraseña
function generarTokenRecuperacion(PDO $pdo, int $userId): string
{
$token = bin2hex(random_bytes(32)); // 64 caracteres hex
$expira = date('Y-m-d H:i:s', strtotime('+1 hour'));
$stmt = $pdo->prepare(
'INSERT INTO tokens_recuperacion (user_id, token, expira_en) VALUES (?, ?, ?)'
);
$stmt->execute([$userId, hash('sha256', $token), $expira]);
// Enviar $token (no el hash) al email del usuario
return $token;
}
function verificarTokenRecuperacion(PDO $pdo, string $token): ?int
{
$stmt = $pdo->prepare(
'SELECT user_id FROM tokens_recuperacion WHERE token = ? AND expira_en > NOW()'
);
$stmt->execute([hash('sha256', $token)]);
$fila = $stmt->fetch(PDO::FETCH_ASSOC);
return $fila ? (int)$fila['user_id'] : null;
}
?>
La documentación oficial de password_hash() y la de password_verify() explican los algoritmos disponibles, el campo PASSWORD_DEFAULT (que cambia con las versiones de PHP) y cómo ajustar el coste en servidores con diferente potencia.
