from MD5 pitfalls to secure rehash-on-login
Hashing passwords with MD5 or SHA-1 leaves the door wide open for brute-force and rainbow-table attacks the moment a database leaks. This article explains how bcrypt and Argon2id actually work, how to use PHP's password_hash() correctly in production code, and which mistakes around salts, cost factors, and rehashing happen most often in real-world applications.
Table of Contents
- 1. Why MD5 and SHA-1 Are Unsuitable for Password Hashing
- 2. Salt: Why Every Password Needs Its Own Random Value
- 3. Work Factor and Cost Factor: Tuning Against Faster Hardware
- 4. bcrypt in Detail: Blowfish, Cost Factor, and Maturity
- 5. Argon2 and Argon2id: Memory Hardness Against GPU/ASIC Attacks
- 6. Using PHP's password_hash() and password_verify() Correctly
- 7. Rehash-on-Login with password_needs_rehash()
- 8. Common Mistakes: Encryption, Missing Salts, Truncation
- 9. Hashing Algorithms Compared Side by Side
- 10. Summary
- 11. FAQ
1. Why MD5 and SHA-1 Are Unsuitable for Password Hashing
MD5 and SHA-1 are cryptographic hash functions designed for integrity checking and checksums, not for storing passwords. Their central design goal is speed: processing as much data per second as possible in order to compute file checksums or signatures efficiently. This exact property, an advantage for checksums, turns into a weakness for passwords the moment a database of hashes falls into an attacker's hands.
Modern graphics cards compute billions of MD5 hashes per second in parallel, because the computation distributes trivially across thousands of GPU cores. An attacker with a stolen database full of MD5 hashes can try billions of password candidates per second (brute force) or use precomputed rainbow tables to reverse unsalted hashes within seconds to minutes. For a typical eight-character password with mixed characters, this is not a theoretical risk but a matter of hours.
Real-world breaches such as the 2012 LinkedIn incident, which involved unsalted SHA-1 hashes, show how quickly millions of passwords ended up in plaintext after a leak. Purpose-built slow hash functions such as bcrypt and Argon2id are deliberately constructed to be several orders of magnitude slower than MD5 or SHA-1, which makes brute-force attacks economically unattractive.
<?php
declare(strict_types=1);
// BROKEN: fast hash, no salt, trivially crackable with a modern GPU
function hashPasswordLegacy(string $password): string
{
return md5($password);
}
function verifyPasswordLegacy(string $password, string $hash): bool
{
// Also vulnerable to timing attacks (non-constant-time comparison)
return md5($password) === $hash;
}
// FIXED: purpose-built slow hash with automatic per-user salt
function hashPasswordSecure(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID);
}
function verifyPasswordSecure(string $password, string $hash): bool
{
// Constant-time comparison built into password_verify()
return password_verify($password, $hash);
}
2. Salt: Why Every Password Needs Its Own Random Value
A salt is a random value that is combined with the password before the hash is computed. It ensures that two users with an identical password end up with different stored hashes. Without a salt, an attacker would immediately see which users share the same password, and a single precomputed table would be enough to compromise all affected accounts at once.
A global salt that is identical for every user is not sufficient. It does prevent generic, publicly available rainbow tables, but a targeted attacker can precompute a specific table for that one known salt and attack all accounts at once. Only a per-user random salt makes precomputation economically pointless, because a separate, expensive table would be required for every single hash. The attacker's effort then scales linearly with the number of users instead of being a one-time cost for the entire database.
Important to understand: a salt does not need to be secret, only unique, and it must come from a cryptographically secure random source, usually at least 16 bytes. PHP's password_hash() generates the salt automatically from a secure source and embeds it directly, in readable form, inside the returned hash string, so developers never have to manage it manually.
3. Work Factor and Cost Factor: Tuning Against Faster Hardware
Adaptive hash functions such as bcrypt and Argon2 have a tunable cost factor that artificially increases the computation time per hash. With bcrypt, the computational effort grows exponentially with the cost factor, because the internal Blowfish round function is repeated 2 to the power of the cost factor times. Increasing the cost factor from 10 to 11 doubles the computation time; a jump to 12 quadruples it compared with the starting value.
The usual target is a hash computation of roughly 100 to 500 milliseconds on typical production hardware: long enough to massively slow down brute-force attacks, short enough not to noticeably affect login latency for real users. This value needs to be recalibrated regularly, because server hardware and CPU performance keep improving.
The cost factor must be raised periodically, since available computing power, especially on GPU and cloud instances, keeps increasing. A cost factor considered safe in 2015 may be insufficient in 2026. Combining rehash-on-login (see section 7) with a regularly increased cost factor keeps existing user accounts automatically at an up-to-date security level, without requiring every user to reset their password.
-- INSECURE: plaintext or weakly hashed password with a fixed global salt column
CREATE TABLE customer_account_insecure (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_plain VARCHAR(255) NOT NULL, -- never store reversible text
global_salt VARCHAR(32) NOT NULL -- same value for every row
);
-- CORRECT: a single column stores the self-describing password_hash() output
-- (algorithm, cost factor, and per-row salt are all embedded in the string)
CREATE TABLE customer_account_secure (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- output of password_hash()
password_hash_updated_at DATETIME NULL -- track last rehash for audits
);
4. bcrypt in Detail: Blowfish, Cost Factor, and Maturity
bcrypt is based on a modified version of the Blowfish block cipher, called Eksblowfish ("expensive key schedule Blowfish"), introduced in 1999 by Niels Provos and David Mazières. The computationally expensive key setup of Blowfish, originally intended for encryption, is deliberately repeated multiple times in bcrypt to slow down the hash computation on purpose instead of speeding it up.
The format of a bcrypt hash, for example $2y$12$N9qo8uLOickgx2ZMRZoMye..., contains the algorithm version, cost factor, and salt in a single self-describing string. password_verify() recognizes these parameters automatically during comparison. One important limitation: bcrypt only processes the first 72 bytes of the input password; anything beyond that is silently ignored, which can lead to entropy loss for very long passphrases.
bcrypt has been in practical use for more than 25 years, is available in practically every programming language and framework, and has been well audited, with no known practical weaknesses when the cost factor is chosen correctly. This maturity and widespread adoption is the main reason bcrypt remains a solid default choice, even though Argon2id is technically superior in many respects.
5. Argon2 and Argon2id: Memory Hardness Against GPU/ASIC Attacks
Argon2 won the Password Hashing Competition in 2015, an open, multi-year contest to select the next generation of password hash functions. There are three variants: Argon2d offers maximum resistance against GPU cracking but is vulnerable to certain side-channel attacks; Argon2i is side-channel resistant and designed specifically for password hashing; Argon2id is a hybrid of both and has been the recommended default choice for most use cases since RFC 9106.
Unlike bcrypt, which primarily consumes CPU time, Argon2id additionally forces the allocation of a configurable block of memory for every hash computation. This memory hardness is the decisive advantage over bcrypt: GPUs and specialized ASICs have thousands of compute cores but only limited fast memory per core. A high memory requirement per hash makes massive parallelization on such hardware economically unattractive, because memory becomes the limiting factor instead of raw compute power.
Argon2id uses three key parameters: memory_cost (memory in KB, e.g. 65536 for 64 MB), time_cost (number of iterations), and threads (degree of parallelism). OWASP recommends a minimum of roughly m=19456 (19 MiB), t=2, p=1 for interactive logins, with higher values further increasing security on adequately sized servers.
<?php
declare(strict_types=1);
// Argon2id with explicit, tunable cost parameters (OWASP-aligned minimums)
$options = [
'memory_cost' => 19456, // 19 MiB of memory per hash computation
'time_cost' => 2, // number of iterations
'threads' => 1, // degree of parallelism
];
$hash = password_hash($plainPassword, PASSWORD_ARGON2ID, $options);
// Resulting hash is self-describing, e.g.:
// $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$hash...
// password_verify() reads memory_cost/time_cost/threads back out automatically
$isValid = password_verify($plainPassword, $hash);
6. Using PHP's password_hash() and password_verify() Correctly
Since PHP 5.5, the Password Hashing API has offered a secure default path that fully encapsulates the algorithm, salt generation, and cost factor. password_hash($password, PASSWORD_BCRYPT) or PASSWORD_ARGON2ID returns a fully self-describing string that can be stored directly in a VARCHAR(255) column, without having to manage salt or cost factor separately.
password_verify($password, $hash) handles the entire comparison, including extracting the algorithm, salt, and cost factor from the stored hash, and performs the comparison in constant time, which prevents timing attacks against the comparison logic. A freshly computed hash must never be compared against the stored hash using == or ===, neither for bcrypt nor for Argon2, because every call to password_hash() produces a different string due to the random salt.
PASSWORD_DEFAULT currently points to bcrypt in current PHP versions, but this may change in future versions, for example to Argon2id, once its adoption and performance characteristics justify it. For that reason, many production codebases prefer explicit constants such as PASSWORD_ARGON2ID whenever a specific algorithm needs to be guaranteed, rather than relying on the shifting default.
<?php
declare(strict_types=1);
final class PasswordAuthenticator
{
/**
* Registers a new user by storing a securely hashed password.
*/
public function register(string $email, string $plainPassword): void
{
$hash = password_hash($plainPassword, PASSWORD_ARGON2ID);
$this->userRepository->save($email, $hash);
}
/**
* Verifies login credentials without ever comparing raw strings.
*/
public function login(string $email, string $plainPassword): bool
{
$storedHash = $this->userRepository->findHashByEmail($email);
if ($storedHash === null) {
// Still run password_verify() against a dummy hash to avoid
// leaking account existence through response timing differences
password_verify($plainPassword, '$argon2id$v=19$m=19456,t=2,p=1$dummysaltdummysalt$dummy');
return false;
}
return password_verify($plainPassword, $storedHash);
}
}
7. Rehash-on-Login with password_needs_rehash()
Because password hashes are irreversible, an existing hash cannot later be migrated to a higher cost factor or a new algorithm without knowing the plaintext password. The only moment the plaintext password is available in the system is during a successful login itself. The rehash-on-login pattern deliberately takes advantage of exactly that moment.
password_needs_rehash($hash, $algorithm, $options) checks whether the stored hash matches the currently desired parameters and returns true if a rehash is needed, for example after raising the cost factor or switching from bcrypt to Argon2id. This check belongs immediately after every successful password_verify() call in the login flow, because that is the only place where the plaintext password is still available in memory.
The same mechanism works for migrating legacy systems from MD5, SHA-1, or custom encryption to password_hash(): on the first successful login using the old method, the password is immediately rehashed with password_hash() and the old hash is overwritten. This way, insecure legacy hashes gradually disappear from the database as active users log in, with no forced password reset for the entire user base.
<?php
declare(strict_types=1);
namespace Mironsoft\Security\Plugin;
use Magento\Customer\Api\AccountManagementInterface;
use Magento\Customer\Api\Data\CustomerInterface;
/**
* Plugin that transparently upgrades stale password hashes after a
* successful authentication, without forcing a password reset.
*/
final class RehashCustomerPasswordPlugin
{
private const CURRENT_ALGORITHM = PASSWORD_ARGON2ID;
private const CURRENT_OPTIONS = ['memory_cost' => 19456, 'time_cost' => 2, 'threads' => 1];
public function __construct(
private readonly \Mironsoft\Security\Model\PasswordHashRepository $hashRepository
) {
}
/**
* Runs after Magento's own authentication succeeds and rehashes the
* password if the stored hash no longer matches current parameters.
*/
public function afterAuthenticate(
AccountManagementInterface $subject,
CustomerInterface $result,
string $username,
string $password
): CustomerInterface {
$currentHash = $this->hashRepository->getHashByCustomerId((int) $result->getId());
if (password_needs_rehash($currentHash, self::CURRENT_ALGORITHM, self::CURRENT_OPTIONS)) {
$newHash = password_hash($password, self::CURRENT_ALGORITHM, self::CURRENT_OPTIONS);
$this->hashRepository->updateHash((int) $result->getId(), $newHash);
}
return $result;
}
}
8. Common Mistakes: Encryption, Missing Salts, Truncation
Encryption instead of hashing is a fundamental mistake: using AES or any other reversible encryption for passwords means that anyone with access to the key can recover every password in plaintext. As soon as the key is compromised, for example through a code leak, an unencrypted backup, or an insider with access to the configuration, every password is immediately exposed. Hashing is deliberately a one-way street; there is no legitimate reason to ever need a stored user password in plaintext.
Missing or weak salts are the second most common mistake. A homegrown approach such as md5($password . 'companyname') uses a static salt that is identical for every user and easy to guess, offering essentially no protection against targeted rainbow tables. Equally problematic is a salt drawn from a non-cryptographic random source such as rand() or mt_rand() instead of random_bytes(), since such values can be predictable under certain circumstances.
Password truncation specifically affects bcrypt: it silently ignores everything beyond 72 bytes, which wastes entropy for very long passphrases and, in rare cases, can lead to collisions for passwords with an identical 72-byte prefix. Argon2id has practically no such limit. Anyone wanting to support very long passphrases should either use Argon2id or clearly document a maximum password length at registration, rather than relying on silent truncation.
9. Hashing Algorithms Compared Side by Side
The choice of hashing algorithm has a direct impact on resilience against offline attacks after a database leak. The following overview compares the most common options in terms of estimated GPU cracking speed, built-in salt, and memory hardness.
| Algorithm | GPU Speed (approx.) | Built-in Salt | Memory Hardness | Recommendation |
|---|---|---|---|---|
| MD5 | Several billion hashes/s | No | No | Do not use |
| SHA-1 | Several billion hashes/s | No | No | Do not use |
| SHA-256 (unsalted) | Several billion hashes/s | No | No | Not for passwords |
| bcrypt (cost 12) | A few thousand hashes/s | Yes | No (CPU-hard) | Recommended |
| Argon2id (OWASP min.) | A few hundred hashes/s | Yes | Yes | Recommended (preferred) |
In practice this means: anyone still using MD5, SHA-1, or unsalted SHA-256 for passwords should migrate to bcrypt or Argon2id immediately, ideally using the rehash-on-login pattern described in section 7. Between bcrypt and Argon2id, Argon2id is the technically more modern choice with better resistance against specialized hardware, while bcrypt remains a solid, well-audited alternative thanks to 25 years of real-world experience and universal availability.
Mironsoft
Security audits, PHP hardening, and Magento security consulting
Want to secure your password hashing professionally?
We review your authentication logic, safely migrate legacy hashes to Argon2id or bcrypt, and implement rehash-on-login, rate limiting, and other safeguards against credential attacks.
Security Audit
Code review of authentication and validation of salt and cost-factor configuration
Migration
Migrate legacy hashes step by step via rehash-on-login, without a forced reset
Hardening
Rate limiting, account lockout, and monitoring against credential stuffing
10. Summary
Secure password hashing with bcrypt and Argon2 solves a clear problem: MD5 and SHA-1 were built for checksums, not passwords, and their speed turns them into an open door for brute-force and rainbow-table attacks after a database leak. bcrypt brings 25 years of real-world experience, universal availability, and an exponentially tunable cost factor. Argon2id adds memory hardness on top of that, which makes specialized GPU and ASIC attacks economically unattractive, and has been the recommended default for new systems since RFC 9106.
PHP's password_hash() and password_verify() fully encapsulate salt generation, algorithm, and cost factor, sparing developers the error-prone task of implementing these manually. password_needs_rehash() in the login flow ensures that cost-factor increases and algorithm changes reach active users automatically and gradually, without a forced password reset for the entire user base. Anyone who consistently combines these building blocks and avoids typical mistakes such as reversible encryption, static salts, or careless password truncation significantly reduces the risk of a catastrophic credential leak.
Password Hashing: bcrypt, Argon2, and Common Mistakes - The Key Takeaways
Avoid MD5/SHA-1
Fast and without a built-in salt, so billions of hashes per second are crackable via GPU. Never use for passwords.
Salt & Cost Factor
A random per-user salt makes rainbow tables pointless. Adjust the cost factor regularly for faster hardware.
bcrypt vs. Argon2id
bcrypt: proven, CPU-hard, 72-byte limit. Argon2id: memory-hard, GPU/ASIC-resistant, recommended since RFC 9106.
Rehash-on-Login
Check password_needs_rehash() after every successful login and migrate hashes gradually.