Password Hashing
Why Passwords Must Be Hashed In simple terms: A website must never store users' passwords as plain text. If the database is ever stolen — through an attack, a leaked backup, or a careless employee — e
Why Passwords Must Be Hashed
In simple terms: A website must never store users' passwords as plain text. If the database is ever stolen — through an attack, a leaked backup, or a careless employee — every password would be exposed, and because many people reuse passwords, attackers could log into their email and bank accounts too. Instead, the site stores a hash: a scrambled fingerprint of the password from which the password cannot be recovered. At login, the entered password is hashed the same way and compared.
Technically: Password hashing uses deliberately slow, salted one-way functions designed for passwords — bcrypt, Argon2id, scrypt. A unique random salt per password ensures that identical passwords produce different hashes and defeats precomputed "rainbow tables". The work factor (cost) makes each guess expensive, slowing down brute-force attacks on stolen hashes. PHP's password_hash(), password_verify(), and password_needs_rehash() implement all of this correctly.
What Not to Use
| Method | Why It Is Unsafe |
|---|---|
| Plain text | Every password is immediately exposed |
base64_encode(), encryption | Reversible — whoever has the key (or no key, for base64) gets the passwords |
md5(), sha1(), hash('sha256', ...) | Extremely fast: modern GPUs test billions of guesses per second; unsalted versions are also vulnerable to rainbow tables |
Home-made schemes (md5($salt . $password)) | Still fast, often subtly flawed |
password_hash() and password_verify()
<?php
$password = 'correct horse battery staple';
$hash = password_hash($password, PASSWORD_DEFAULT);
$secondHash = password_hash($password, PASSWORD_DEFAULT);
echo strlen($hash) . " characters, starts with " . substr($hash, 0, 7) . "\n";
var_dump($hash !== $secondHash);
var_dump(password_verify('correct horse battery staple', $hash));
var_dump(password_verify('Correct horse battery staple', $hash));
print_r(password_get_info($hash));
Output (PHP 8.4+; the hash itself is different every time)
60 characters, starts with $2y$12$
bool(true)
bool(true)
bool(false)
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 12
)
)
How It Works
password_hash()generates a random salt, hashes the password with the selected algorithm, and returns a string containing everything needed for verification: the algorithm ($2y$= bcrypt), the cost (12), the salt, and the hash.- Hashing the same password twice gives different results, because each hash has its own salt.
password_verify()reads the algorithm, cost, and salt from the stored hash, hashes the entered password the same way, and compares the results in constant time. It returnstrueonly for the exact password (passwords are case-sensitive).password_get_info()describes a stored hash.
PASSWORD_DEFAULT currently means bcrypt; PHP 8.4 raised its default cost from 10 to 12. Because the default may change in future PHP versions, store hashes in a column that can hold at least 255 characters (VARCHAR(255)).
Choosing the Algorithm
| Constant | Algorithm | Notes |
|---|---|---|
PASSWORD_DEFAULT | bcrypt (today) | Recommended; follows PHP's best choice over time |
PASSWORD_BCRYPT | bcrypt | Uses only the first 72 bytes of the password |
PASSWORD_ARGON2ID | Argon2id | Memory-hard; recommended by OWASP; available when PHP is built with Argon2 or libsodium support |
$hash = password_hash($password, PASSWORD_ARGON2ID);
// $argon2id$v=19$m=65536,t=4,p=1$...
The cost should make one hash take very roughly 100–500 ms on your server: slow enough to hinder attackers, fast enough for users. Test on your hardware before raising it.
Upgrading Hashes Over Time: password_needs_rehash()
When the algorithm or cost changes, existing hashes should be upgraded. This can only happen at login, when the plain password is available:
<?php
$password = 'correct horse battery staple';
$oldHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); // created years ago
if (password_verify($password, $oldHash) && password_needs_rehash($oldHash, PASSWORD_DEFAULT, ['cost' => 12])) {
$newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
echo "Upgraded: " . substr($oldHash, 0, 7) . " → " . substr($newHash, 0, 7) . "\n";
}
Output
Upgraded: $2y$10$ → $2y$12$
After a successful login, the application checks whether the stored hash still matches the current settings and, if not, stores a fresh hash. Over time, all active accounts migrate automatically.
Practical Example: Registration and Login
<?php
declare(strict_types=1);
final class AuthService
{
private array $users = []; // email => password hash (a database table in real code)
public function register(string $email, string $password): string
{
$email = strtolower(trim($email));
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return 'Please enter a valid email address.';
}
if (mb_strlen($password) < 8) {
return 'Use at least 8 characters for your password.';
}
if (isset($this->users[$email])) {
return 'This email is already registered.';
}
$this->users[$email] = password_hash($password, PASSWORD_DEFAULT);
return 'Account created.';
}
public function login(string $email, string $password): bool
{
$email = strtolower(trim($email));
$hash = $this->users[$email] ?? null;
if ($hash === null) {
password_verify($password, self::dummyHash()); // keep timing similar
return false;
}
if (!password_verify($password, $hash)) {
return false;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$this->users[$email] = password_hash($password, PASSWORD_DEFAULT);
}
return true;
}
private static function dummyHash(): string
{
static $hash = null;
return $hash ??= password_hash('dummy-password-for-timing', PASSWORD_DEFAULT);
}
}
$auth = new AuthService();
echo $auth->register('Asha@Example.com', 'short') . "\n";
echo $auth->register('Asha@Example.com', 'correct horse battery staple') . "\n";
foreach ([['asha@example.com', 'correct horse battery staple'], ['asha@example.com', 'wrong'], ['nobody@example.com', 'whatever']] as [$email, $password]) {
echo $auth->login($email, $password) ? "Login OK for $email\n" : "Invalid email or password.\n";
}
Output
Use at least 8 characters for your password.
Account created.
Login OK for asha@example.com
Invalid email or password.
Invalid email or password.
Step-by-Step Explanation
- Registration normalizes the email, enforces a minimum length, and stores only the hash.
- Login looks up the hash and verifies the password; on success, it upgrades outdated hashes.
- For unknown emails, a dummy hash (created once with the same settings as real hashes) is verified anyway, so the response time is similar to a wrong password for a real account. This makes it harder to discover which emails are registered by measuring response times.
- Both "unknown email" and "wrong password" return the same message, for the same reason.
In a real application, users are stored in the database (with a UNIQUE email index), the session ID is regenerated after login (see Session Security), and failed attempts are rate-limited.
Password Policies
Modern guidance (such as NIST SP 800-63B) recommends:
| Do | Avoid |
|---|---|
| Require a minimum length (at least 8 characters; longer is better) | Forced composition rules ("one symbol, one digit") |
| Allow long passwords and passphrases (at least 64 characters) | Silently truncating passwords |
| Allow all characters, including spaces and Unicode | Periodic forced password changes without a reason |
| Check new passwords against lists of breached passwords | Password hints or security questions |
| Rate-limit login attempts and offer multi-factor authentication | Telling users which part of the login was wrong |
Breached-password checks can be done with services such as Have I Been Pwned using its k-anonymity API, which never receives the full password or its full hash.
Common Mistakes
Using Fast Hash Functions
Effect: Stolen hashes are cracked quickly.
Fix: Use password_hash().
Comparing Hashes Yourself
Effect: password_hash($input) === $storedHash is always false (different salts), and manual comparisons can leak timing information.
Fix: Always use password_verify().
Too-Short Database Columns
Effect: Hashes are truncated and logins fail after algorithm changes.
Fix: Use VARCHAR(255).
Revealing Whether an Account Exists
Effect: Attackers can enumerate registered emails.
Fix: Use identical messages and similar timing for all login failures.
Logging or Emailing Passwords
Effect: Plain-text passwords end up in logs and inboxes.
Fix: Never log passwords; send password-reset links, never passwords.
Best Practices
- Hash passwords with
password_hash($password, PASSWORD_DEFAULT)(or Argon2id) and verify withpassword_verify(). - Store hashes in
VARCHAR(255)columns and upgrade them withpassword_needs_rehash()at login. - Enforce sensible length-based policies and check against breached passwords.
- Use identical error messages, rate limiting, and multi-factor authentication for logins.
- Never store, log, or transmit passwords in plain text.
Official reference: https://www.php.net/manual/en/faq.passwords.php