En resumen: en PHP, las contraseñas se guardan con password_hash() y se comprueban con password_verify(). El valor predeterminado PASSWORD_DEFAULT usa actualmente bcrypt, desde PHP 8.4 con factor de coste 12; donde PHP esté compilado con Argon2, PASSWORD_ARGON2ID es una buena alternativa. Los hashes MD5 o SHA-1 antiguos se migran envolviéndolos de inmediato en password_hash y sustituyéndolos por un hash directo en el siguiente inicio de sesión.
Qué tiene que ver una fuga de la base de datos con sus hashes
Tras una intrusión en un servidor web, la base de datos es uno de los primeros objetivos. Quien, como es posible con PHP2Shell, puede ejecutar código en el servidor, lee también las credenciales de la aplicación y, con ellas, la tabla de cuentas de usuario. Que las contraseñas contenidas en ella resulten legibles al cabo de horas o de años no se decide el día del ataque, sino en la programación.
Sorprendentemente, muchas aplicaciones siguen guardando contraseñas con procedimientos que nunca se concibieron para ese fin: MD5, SHA-1, un SHA-256 simple o incluso cifrado reversible. Estos procedimientos son rápidos, y ese es justo el problema. Con ellos, los atacantes pueden probar muchísimos candidatos por segundo.
PHP incorpora desde hace tiempo una solución que calcula despacio, genera automáticamente un salt para cada contraseña y guarda el procedimiento y los parámetros en el propio hash. Solo hay que usarla de forma consecuente.
password_hash en PHP: ¿bcrypt o Argon2id?
password_hash() conoce varios algoritmos. La selección según el manual de PHP:
| Constante | Algoritmo | Observación |
|---|---|---|
PASSWORD_DEFAULT |
actualmente bcrypt | Valor predeterminado, puede cambiar en versiones futuras |
PASSWORD_BCRYPT |
bcrypt | Factor de coste 12 por defecto desde PHP 8.4, antes 10; solo cuentan los primeros 72 bytes |
PASSWORD_ARGON2I |
Argon2i | Desde PHP 7.2, solo si PHP está compilado con soporte para Argon2 |
PASSWORD_ARGON2ID |
Argon2id | Desde PHP 7.3, misma condición |
OWASP recomienda Argon2id como primera opción, con al menos 19 MiB de memoria, dos iteraciones y un hilo, y bcrypt sobre todo para sistemas existentes. Para la mayoría de las aplicaciones PHP, la diferencia en la práctica es menor que la diferencia entre cualquiera de estos algoritmos y MD5. Es más importante usar alguno de ellos y ajustar los parámetros con regularidad.
Una breve prueba muestra si Argon2id está disponible:
<?php
var_dump(defined('PASSWORD_ARGON2ID'));
var_dump(password_algos());
Guardar y comprobar: un patrón completo
El ejemplo siguiente muestra el registro y el inicio de sesión con actualización automática de hashes obsoletos. La comprobación de longitud protege frente al límite de 72 bytes de bcrypt:
<?php
declare(strict_types=1);
const PASSWORT_ALGO = PASSWORD_DEFAULT;
function passwortSpeichern(string $passwort): string
{
if (strlen($passwort) > 72) {
throw new InvalidArgumentException('Passwort zu lang für das gewählte Verfahren.');
}
return password_hash($passwort, PASSWORT_ALGO);
}
function passwortPruefen(string $eingabe, string $gespeicherterHash, callable $hashAktualisieren): bool
{
if (!password_verify($eingabe, $gespeicherterHash)) {
return false;
}
if (password_needs_rehash($gespeicherterHash, PASSWORT_ALGO)) {
$hashAktualisieren(password_hash($eingabe, PASSWORT_ALGO));
}
return true;
}
strlen() cuenta bytes, no caracteres. Aquí es lo deseado, porque el límite de bcrypt se refiere a bytes. Según el manual, la columna de la base de datos debería admitir más de 60 caracteres; 255 es una buena elección. No establezca nunca un salt propio: la opción está obsoleta y se ignora desde PHP 8.0.
Con el cambio a PHP 8.4, el factor de coste predeterminado de bcrypt sube de 10 a 12. Gracias a password_needs_rehash(), los hashes existentes se actualizan automáticamente en el siguiente inicio de sesión. En sistemas muy cargados, conviene hacer antes de la migración una breve prueba de carga del inicio de sesión.
Mida el factor de coste en su servidor de producción. Una comprobación individual debería consumir un tiempo de cálculo perceptible, pero quedar claramente por debajo de un segundo, para que los inicios de sesión no se conviertan en un cuello de botella bajo carga.
Sustituir hashes MD5 y SHA-1 antiguos
El verdadero desafío suele ser lo ya existente. Usted no conoce las contraseñas, así que no puede simplemente volver a aplicarles un hash. Esperar a que todos los usuarios vuelvan a iniciar sesión deja desprotegidas las cuentas inactivas. Por eso OWASP describe un procedimiento en dos etapas:
- Envolver de inmediato: envolver con
password_hash()mediante un script cada valor MD5 o SHA-1 existente y guardarlo con una marca, por ejemplo en una columnahash_typ. - Comprobar en el inicio de sesión: para las cuentas marcadas, convertir primero la entrada con el algoritmo antiguo y comprobarla después con
password_verify()frente al valor envuelto. - Sustituir directamente: tras un inicio de sesión correcto, generar un hash normal a partir del texto en claro y quitar la marca.
- Depurar el remanente: poner en restablecimiento de contraseña las cuentas que, pasado un plazo establecido, aún tengan hashes envueltos.
<?php
// Etapa 1 (una sola vez mediante script): $neu = password_hash($alterMd5Wert, PASSWORD_DEFAULT);
// Etapas 2 y 3 en el inicio de sesión:
if ($konto['hash_typ'] === 'md5-umhuellt') {
$ok = password_verify(md5($eingabe), $konto['passwort_hash']);
if ($ok) {
$speichere(password_hash($eingabe, PASSWORD_DEFAULT), 'direkt');
}
}
Los hashes envueltos son mejores que los valores MD5 desnudos, pero más débiles que los hashes directos. Son una solución de transición, no un objetivo. Tras un incidente en el que la base de datos pudiera haberse visto afectada, esta migración no sustituye la petición a los usuarios de que cambien sus contraseñas. Qué indicios apuntan a un incidente así lo describe ¿Ya estoy comprometido?.
Pepper, cifrado y otros caminos equivocados
Algunas ideas suenan a más seguridad, pero aportan poco o perjudican:
- Construcciones de hash propias, como varios SHA-256 seguidos con un salt generado por uno mismo, son más rápidas que bcrypt y, por tanto, más débiles.
- Las contraseñas cifradas son reversibles. Si la clave está en el mismo servidor, el cifrado apenas protege tras una intrusión.
- Un pepper, es decir, un valor secreto adicional para todas las contraseñas, puede ser una capa extra si se guarda separado de la base de datos. Pero no sustituye a un buen algoritmo de hash y dificulta cambios posteriores.
- El pre-hash antes de bcrypt, para sortear el límite de 72 bytes, entraña según OWASP riesgos propios. Un límite de longitud claro o Argon2id es más sencillo.
También en las reglas para contraseñas nuevas merece la pena fijarse en el estado de la técnica: la longitud protege más que las exigencias complicadas sobre caracteres especiales, y deberían rechazarse las contraseñas muy comunes o que ya han aparecido en filtraciones de datos. Los cambios forzados a intervalos fijos y sin motivo llevan, según la experiencia, más a patrones predecibles que a contraseñas más fuertes.
En los sistemas de inicio de sesión, las contraseñas tampoco deberían estar nunca solas: los métodos de dos factores para administradores y un freno frente a los intentos masivos de acceso complementan el almacenamiento. Ambos temas los trata la guía Proteger los inicios de sesión de administración frente a la fuerza bruta. Para enlaces de restablecimiento y códigos de un solo uso rigen otras reglas; véase Números aleatorios y tokens seguros en PHP. Una visión general de otras funciones la ofrece Funciones de seguridad integradas de PHP.
¿Cómo ayuda PHP SOS en concreto?
- Comprobamos cómo guardan las contraseñas su aplicación y sus plugins y encontramos algoritmos obsoletos también en funciones secundarias.
- Planificamos la migración de lo existente con envoltura, migración en el inicio de sesión y plazo para las cuentas restantes.
- Ajustamos el factor de coste o los parámetros de Argon2 a sus servidores.
- Tras un incidente, ayudamos en la decisión sobre los restablecimientos de contraseña y en su aplicación técnica.
Ofrecemos la revisión de software PHP propio como auditoría de seguridad y prueba de penetración.
Preguntas frecuentes
¿Qué algoritmo usa password_hash con PASSWORD_DEFAULT?
Actualmente bcrypt. El manual de PHP advierte de que el valor predeterminado puede cambiar cuando se incorporen algoritmos más fuertes. Por eso la columna de la base de datos debería admitir más de 60 caracteres; el manual cita 255 como buena elección.
¿Debería cifrar las contraseñas en lugar de aplicarles un hash?
No. Las contraseñas cifradas pueden volver a leerse con la clave, y esa clave suele estar en el mismo servidor. Un hash de contraseña es de sentido único: la aplicación solo puede comprobar si una entrada coincide.
¿Qué factor de coste debería usarse con bcrypt?
Desde PHP 8.4 el valor predeterminado es 12; antes era 10. OWASP cita 10 como límite inferior y recomienda subir tanto como toleren sus servidores. Mida en su propio hardware cuánto tarda una comprobación.
¿Qué significa el límite de 72 bytes de bcrypt?
bcrypt solo tiene en cuenta los primeros 72 bytes de una contraseña; todo lo posterior se trunca. Con caracteres acentuados, un carácter suele ocupar dos bytes. Por eso, limite expresamente la longitud o use Argon2id si su PHP está compilado con él.
¿Deben los usuarios cambiar sus contraseñas tras una intrusión?
Si no puede descartarse que la base de datos haya sido exfiltrada, debería disponerlo. Los buenos hashes ralentizan mucho el descifrado, pero las contraseñas débiles siguen siendo vulnerables. Si existe además una obligación de notificación, aclárelo con su delegado de protección de datos.