Contraseñas seguras en PHP: de MD5 a bcrypt y Argon2 (actualizado 2026)

Artículo original de septiembre de 2000. Actualizado en mayo de 2026 por David Carrero para reflejar las prácticas actuales de seguridad en PHP 8.

Aviso de seguridad (2026)

Antes de entrar en materia, un aviso importante: MD5 lleva décadas siendo inadecuado para almacenar contraseñas. En 2004 se demostraron las primeras colisiones prácticas en MD5, y desde 2008 herramientas como Hashcat o John the Ripper pueden probar miles de millones de hashes MD5 por segundo usando una GPU doméstica. Las tablas arcoíris (rainbow tables) permiten revertir la mayoría de contraseñas cortas en segundos.

Si tu aplicación todavía guarda contraseñas con md5($password), tienes una vulnerabilidad seria. El artículo original de 2000 fue correcto para su época, pero hoy PHP incluye password_hash() y password_verify(), que hacen el trabajo bien desde la versión 5.5 y mejor aún en PHP 8. Si solo quieres la solución moderna, salta directamente a esa sección.

Dicho esto, la sección original tiene valor histórico y su enfoque de añadir un nonce aleatorio antes de hashear era un intento razonable de mitigar los ataques de replay. Lo conservamos a continuación.


El algoritmo MD5 (contexto histórico, año 2000)

MD5 es una función de cifrado tipo hash que acepta una cadena de texto como entrada y devuelve un número de 128 bits. Sus ventajas, cuando se escribió este artículo, eran la dificultad computacional de reconstruir la cadena original a partir del resultado y la imposibilidad práctica de encontrar dos cadenas que generasen el mismo resultado.

Esto permitía usar el algoritmo para transmitir contraseñas por un medio inseguro: se cifraba la contraseña, se enviaba cifrada, y en el destino se volvía a cifrar de la misma manera para comparar. El problema, que no era evidente en 2000, es que MD5 fue diseñado para velocidad, no para seguridad de contraseñas. Un algoritmo rápido es exactamente lo que no quieres para hashear passwords: facilita los ataques de fuerza bruta.

El formulario de Login (técnica original de 2000)

El documento HTML original contenía un formulario donde el usuario introducía usuario y contraseña. Al pulsar enviar, se añadía un número aleatorio al password y se cifraba con MD5 en JavaScript. El número aleatorio y el hash resultante viajaban al servidor, que verificaba calculando el mismo hash.

<html><head><title></title>
<script language="JavaScript" src="md5.js"></script>
<script language="JavaScript">
numero = Math.random().toString();

function calculaMD5() {
  var pw = document.forms["login"].elements["password"].value;
  pw += numero;
  return calcMD5(pw);
}

function enviaMD5(hash) {
  document.forms["login"].elements["cifrado"].value = hash;
  document.forms["login"].elements["numero"].value = numero;
  document.forms["login"].submit();
}
</script></head>

<body>
<form action="auth.php3" method="POST" name="login">
  Usuario: <input type="Text" name="usuario"><br>
  Password: <input type="Password" name="password"><br>
  <input type="Hidden" name="cifrado" value="">
  <input type="Hidden" name="numero" value="">
  <input type="Submit" value=" Login " onClick="enviaMD5(calculaMD5())">
</form>
</body></html>

El script en PHP3 (técnica original de 2000)

PHP3 era en 2000 un lenguaje de server-side scripting gratuito y open source. Ya incluía una implementación nativa de MD5. El script extraía el password del usuario de la base de datos, le añadía el número aleatorio recibido del formulario, calculaba el MD5 y comparaba con el valor cifrado enviado por el cliente.

<?php
$password = passwordUsuario($usuario);

$serverpassword = strtolower(md5($password . $numero));
$clientpassword = strtolower($cifrado);

if ($serverpassword === $clientpassword) {
    echo "<p>¡Correcto!";
} else {
    echo "<p>Acceso denegado.";
}
?>

La técnica era ingeniosa para la época: el nonce aleatorio evitaba ataques de replay (enviar el mismo hash capturado para autenticarse sin conocer la contraseña). Sin embargo, la contraseña se guardaba en texto plano en la base de datos y el propio MD5 quedaba expuesto si se volcaba la tabla.


Cómo hacerlo bien en PHP 8 (2026)

Desde PHP 5.5 existe password_hash(), y desde PHP 7.2 el algoritmo por defecto es Argon2i. No necesitas librerías externas, no necesitas implementar nada a mano. Estas dos funciones hacen todo lo que necesitas:

  • password_hash($password, PASSWORD_BCRYPT): genera un hash bcrypt con sal aleatoria incorporada. El resultado incluye el algoritmo, el coste y la sal en una sola cadena.
  • password_verify($password, $hash): compara una contraseña en texto plano con un hash almacenado, sin exponer la contraseña a ataques de tiempo.

Bcrypt incorpora una sal aleatoria en cada hash, así que dos usuarios con la misma contraseña tendrán hashes distintos. Eso hace inútiles las rainbow tables. Además, bcrypt tiene un parámetro de coste ajustable: puedes aumentarlo conforme crece la potencia del hardware.

Registrar un usuario nuevo

<?php
// Conexión con PDO (sustituye mysql_* deprecated desde PHP 5.5, eliminado en PHP 7)
$pdo = new PDO(
    'mysql:host=localhost;dbname=mi_app;charset=utf8mb4',
    'usuario_db',
    'contraseña_db',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

function registrarUsuario(PDO $pdo, string $nombre, string $email, string $password): int
{
    // Hashear con bcrypt, coste 12 (ajusta según tu servidor)
    $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

    $stmt = $pdo->prepare(
        "INSERT INTO usuarios (nombre, email, password_hash) VALUES (:nombre, :email, :hash)"
    );
    $stmt->execute([':nombre' => $nombre, ':email' => $email, ':hash' => $hash]);

    return (int) $pdo->lastInsertId();
}

// Uso
$id = registrarUsuario($pdo, 'María García', '[email protected]', $_POST['password']);
echo "Usuario creado con ID: $id";

Verificar el login

<?php
function verificarLogin(PDO $pdo, string $email, string $password): ?array
{
    $stmt = $pdo->prepare("SELECT id, nombre, password_hash FROM usuarios WHERE email = :email");
    $stmt->execute([':email' => $email]);
    $usuario = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$usuario) {
        return null; // Email no encontrado
    }

    if (!password_verify($password, $usuario['password_hash'])) {
        return null; // Contraseña incorrecta
    }

    // Opcional: actualizar el hash si el coste ha cambiado
    if (password_needs_rehash($usuario['password_hash'], PASSWORD_BCRYPT, ['cost' => 12])) {
        $nuevoHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
        $pdo->prepare("UPDATE usuarios SET password_hash = :hash WHERE id = :id")
            ->execute([':hash' => $nuevoHash, ':id' => $usuario['id']]);
    }

    return $usuario;
}

// Uso
session_start();
$usuario = verificarLogin($pdo, $_POST['email'], $_POST['password']);

if ($usuario) {
    $_SESSION['usuario_id'] = $usuario['id'];
    $_SESSION['usuario_nombre'] = $usuario['nombre'];
    header('Location: /dashboard');
    exit;
} else {
    $error = 'Email o contraseña incorrectos';
}

¿Qué pasa si ya tienes contraseñas en MD5 en producción?

La migración es más sencilla de lo que parece. El truco es actualizar el hash la próxima vez que el usuario se autentique correctamente:

<?php
function loginConMigracion(PDO $pdo, string $email, string $password): ?array
{
    $stmt = $pdo->prepare("SELECT id, nombre, password_hash, password_md5_legacy FROM usuarios WHERE email = :email");
    $stmt->execute([':email' => $email]);
    $usuario = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$usuario) {
        return null;
    }

    $autenticado = false;

    if ($usuario['password_hash']) {
        // Ya tiene hash moderno
        $autenticado = password_verify($password, $usuario['password_hash']);
    } elseif ($usuario['password_md5_legacy']) {
        // Todavía tiene MD5 antiguo
        $autenticado = (md5($password) === $usuario['password_md5_legacy']);

        if ($autenticado) {
            // Migrar a bcrypt en este mismo login
            $nuevoHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
            $pdo->prepare("UPDATE usuarios SET password_hash = :hash, password_md5_legacy = NULL WHERE id = :id")
                ->execute([':hash' => $nuevoHash, ':id' => $usuario['id']]);
        }
    }

    return $autenticado ? $usuario : null;
}

Con este patrón, la migración es transparente. Cada usuario que se autentique con su contraseña actual obtendrá automáticamente un hash bcrypt; los que lleven meses sin entrar seguirán con MD5 hasta que lo hagan. Cuando estés seguro de que todos han migrado, puedes eliminar la columna MD5.

Argon2: la alternativa más moderna

PHP 7.2 introdujo soporte para Argon2i y PHP 7.3 añadió Argon2id, que es el recomendado actualmente por el NIST y la OWASP. Argon2id es más resistente que bcrypt a ciertos ataques de hardware especializado. Para usarlo, solo cambia la constante:

<?php
// Argon2id con parámetros personalizados
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 65536,  // 64 MB de RAM
    'time_cost'   => 4,       // 4 iteraciones
    'threads'     => 2,       // 2 hilos paralelos
]);

Si tu servidor tiene PHP 8 y no necesitas compatibilidad hacia atrás con PHP 5, usa Argon2id. Si necesitas compatibilidad o simpleza, bcrypt es perfectamente válido.

Buenas prácticas resumidas

  • Nunca guardes contraseñas en texto plano ni con MD5/SHA1/SHA256 sin sal.
  • Usa siempre password_hash() y password_verify(). No implementes tu propio sistema.
  • La conexión debe ir siempre por HTTPS. El hash protege la base de datos; HTTPS protege el tránsito.
  • Usa password_needs_rehash() para actualizar hashes cuando aumentes el coste.
  • Limita los intentos de login: 5 fallos consecutivos merecen un bloqueo temporal o captcha.

Para ver cómo integrar la autenticación con PDO en una aplicación completa, consulta el artículo Cómo interactuar con una base de datos MySQL usando PHP. Si necesitas implementar también búsquedas sobre tus datos, el artículo sobre cómo programar un buscador con PHP y MySQL (FULLTEXT y PDO) cubre exactamente ese caso.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP
ARTÍCULO ANTERIOR