Never store the password itself
When people sign up, they trust you with a password they probably use elsewhere too. If your database ever leaks, and many do, the passwords inside must be useless to the attacker. So you never store the password. You store a hash: the result of a one-way function that cannot be turned back into the password, but that lets you check whether a typed password is the same one.
Not every hash will do. General-purpose hashes such as md5(), sha1() and even sha256 are designed to be fast, and attackers can try billions of guesses per second against them. Without a salt (a random value mixed into each password), identical passwords also give identical hashes, which makes lookup tables effective:
<?php
echo md5('password123') . "\n";
echo md5('password123') . "\n";
echo sha1('password123') . "\n";482c811da5d5b4bc6d497ffa98491e38
482c811da5d5b4bc6d497ffa98491e38
cbfdac6008f9cab4083784cbd1874f76618d2a97Search the web for that md5 value and you will find "password123" immediately. Never use these functions for passwords.
password_hash
PHP's password_hash() uses an algorithm built for passwords: deliberately slow, with a fresh random salt every time. PASSWORD_DEFAULT is currently bcrypt, and PHP will move it to stronger algorithms in future versions:
<?php
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
echo $hash . "\n";
echo password_hash('correct horse battery staple', PASSWORD_DEFAULT) . "\n";Run it and you get two different strings, each about 60 characters, something like $2y$10$WqXH.... The parts are the algorithm (2y, bcrypt), the cost (10), the 22-character salt and the hash itself, all in one string. Store that whole string. Use a column of at least VARCHAR(255), so longer future formats fit.
<?php
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
$info = password_get_info($hash);
echo $info['algoName'] . "\n";
echo strlen($hash) . " characters\n";
var_dump(str_starts_with($hash, '$2y$10$'));bcrypt
60 characters
bool(true)password_verify
At login, give password_verify() the typed password and the stored hash. It reads the algorithm, cost and salt from the hash, repeats the work and compares safely:
<?php
$stored = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
var_dump(password_verify('correct horse battery staple', $stored));
var_dump(password_verify('Correct horse battery staple', $stored));
var_dump(password_verify('', $stored));bool(true)
bool(false)
bool(false)Never try to check a login by hashing the typed password again and comparing strings: the salt is random, so the strings never match.
A registration and login flow
Here is the whole flow with a database. The same patterns appear in the Sessions lesson's login page:
<?php
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL)');
function register(PDO $pdo, string $email, string $password): string
{
if (mb_strlen($password) < 12) {
return "Password must be at least 12 characters.";
}
$statement = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$statement->execute([strtolower(trim($email)), password_hash($password, PASSWORD_DEFAULT)]);
return "Registered $email";
}
function login(PDO $pdo, string $email, string $password): ?int
{
$statement = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$statement->execute([strtolower(trim($email))]);
$user = $statement->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
return (int) $user['id'];
}
return null;
}
echo register($pdo, 'asha@example.com', 'short') . "\n";
echo register($pdo, 'asha@example.com', 'correct horse battery staple') . "\n";
var_dump(login($pdo, 'Asha@Example.com', 'correct horse battery staple'));
var_dump(login($pdo, 'asha@example.com', 'wrong password'));
var_dump(login($pdo, 'nobody@example.com', 'anything'));Password must be at least 12 characters.
Registered asha@example.com
int(1)
NULL
NULLDetails worth copying:
- Emails are trimmed and lowercased on both sign-up and login.
- A wrong password and an unknown email give the same result, so the form does not reveal which emails have accounts.
- Length matters more than complexity rules. Current guidance (for example NIST's) is to allow long passphrases, require a reasonable minimum length, and check new passwords against lists of known leaked passwords, rather than forcing symbols.
Upgrading hashes over time
Computers get faster, so the cost should rise over the years, and PASSWORD_DEFAULT may change. You cannot re-hash stored passwords without knowing them, but at login you do know the password for a moment. password_needs_rehash() says whether the stored hash is out of date:
<?php
$old = password_hash('correct horse battery staple', PASSWORD_DEFAULT, ['cost' => 10]);
$options = ['cost' => 12];
if (password_verify('correct horse battery staple', $old) && password_needs_rehash($old, PASSWORD_DEFAULT, $options)) {
$new = password_hash('correct horse battery staple', PASSWORD_DEFAULT, $options);
echo "Re-hashed with cost " . password_get_info($new)['options']['cost'] . "\n";
// UPDATE users SET password_hash = ? WHERE id = ?
}Re-hashed with cost 12Each step up in cost doubles the work. Choose the highest cost that keeps a login check under about a quarter of a second on your server.
Beyond the hash
Hashing is one layer. A complete login also needs: HTTPS everywhere, rate limiting of login attempts (this site's learner login allows 5 per minute), session_regenerate_id(true) after login, a password reset by email with a single-use, expiring token, and ideally two-factor authentication for accounts that matter.
Common mistakes
- Using md5(), sha1() or your own scheme for passwords.
- Comparing hashes with === instead of
password_verify(). - A column too short for the hash, which silently cuts it and breaks every login.
- Telling visitors whether the email or the password was wrong.
Exercise
Write a function changePassword(PDO $pdo, int $userId, string $current, string $new): string that checks the current password, rejects a new one shorter than 12 characters or equal to the current one, and otherwise stores a new hash. Test it on the user registered above.
Show one possible answer
<?php
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, password_hash TEXT)');
$pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')->execute(['asha@example.com', password_hash('correct horse battery staple', PASSWORD_DEFAULT)]);
function changePassword(PDO $pdo, int $userId, string $current, string $new): string
{
$statement = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
$statement->execute([$userId]);
$hash = $statement->fetchColumn();
if ($hash === false || !password_verify($current, $hash)) {
return "Current password is wrong.";
}
if (mb_strlen($new) < 12) {
return "New password must be at least 12 characters.";
}
if ($new === $current) {
return "New password must be different.";
}
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')->execute([password_hash($new, PASSWORD_DEFAULT), $userId]);
return "Password changed.";
}
echo changePassword($pdo, 1, 'guess', 'a much longer passphrase') . "\n";
echo changePassword($pdo, 1, 'correct horse battery staple', 'too short') . "\n";
echo changePassword($pdo, 1, 'correct horse battery staple', 'a much longer passphrase') . "\n";
echo changePassword($pdo, 1, 'a much longer passphrase', 'a much longer passphrase') . "\n";Current password is wrong.
New password must be at least 12 characters.
Password changed.
New password must be different.Key points
- Never store passwords as plain text or with fast hashes such as md5() or sha1().
- password_hash($password, PASSWORD_DEFAULT) creates a slow, salted hash; store the whole string it returns.
- password_verify($password, $hash) checks a login. Do not compare hashes yourself.
- password_needs_rehash() tells you when to re-hash at login because the algorithm or cost has changed.