password_hash() y password_verify() en PHP: almacenar contraseñas de forma segura

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 4–31
]);
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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP