Password Hashing with password_hash()
A password database is one of the highest-value targets an attacker can compromise, because most people reuse the same password across several sites. How you store passwords determines how much damage a single database leak causes — the difference between "the attacker got a list of unreadable hashes" and "the attacker now has the actual login credentials for every user, on every site those users reused it on." PHP's built-in password_hash() and password_verify() functions exist specifically so developers do not have to design this scheme themselves.
Why plaintext, MD5, and SHA1 are unacceptable
Storing a password as plaintext means anyone who reads the database — through a breach, a misconfigured backup, or a rogue employee — instantly has every user's real password. Hashing it with a general-purpose function like md5() or sha1() looks safer at first glance, because the stored value is not the password itself, but both algorithms were designed for speed and data-integrity checks, not for protecting secrets. Modern hardware can compute billions of MD5 or SHA1 hashes per second, so an attacker who steals a table of these hashes can simply hash every password in a huge leaked-password dictionary and compare, recovering most of the table within hours. Precomputed "rainbow tables" of common passwords make this even faster if no per-password salt was used. Neither algorithm was ever intended to be slow, and slowness is exactly the property a password hash needs.
registration.php (vulnerable)
<?php $hash = md5($_POST['password']); // fast, unsalted, crackable at scale // stored directly in the users table
The fix: password_hash() and password_verify()
password_hash() is purpose-built for this problem. It uses a deliberately slow, memory-hard algorithm, automatically generates a fresh random salt for every password (so two users with the same password get completely different stored hashes), and encodes the algorithm, cost parameters, salt, and hash together into one string you can store in a single database column.
registration.php (safe)
<?php
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT);
// store $hash in the database, e.g. a VARCHAR(255) column
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
$stmt->execute([$_POST['username'], $hash]);$2y$10$uWZ2s8f3qk1yq0V5m9x2wOe7ZP.NcKz1r6Rr4o9x8Yh3F5jK2Lqbi
That output string is self-contained: it embeds which algorithm produced it (the $2y$ prefix indicates bcrypt), the cost factor (10), the random salt, and the resulting hash, all in one value. You never need to store the salt in a separate column — password_hash() has already folded it in.
To check a login attempt, never hash the submitted password again and compare strings yourself — pass the submitted password and the stored hash straight to password_verify(), which knows how to extract the algorithm and salt from the stored string and repeat the same computation.
login.php (safe)
<?php
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$_POST['username']]);
$user = $stmt->fetch();
if ($user && password_verify($_POST['password'], $user['password_hash'])) {
// correct password
} else {
// wrong username or password — do not reveal which
}PASSWORD_DEFAULT vs PASSWORD_ARGON2ID
PASSWORD_DEFAULT currently maps to bcrypt, and PHP's maintainers may change what it points to in a future version as stronger algorithms become the recommended baseline — which is exactly why using the constant, rather than hardcoding an algorithm name, is the right default. If the Sodium extension is available and your application specifically wants Argon2id (the winner of the Password Hashing Competition and the algorithm OWASP currently recommends first), you can request it explicitly.
Requesting Argon2id explicitly
<?php
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // KiB
'time_cost' => 4,
'threads' => 2,
]);Both options are dramatically stronger than a raw hash function because they are tunable to be slow, and that slowness is the whole point — it makes brute-forcing a stolen hash table computationally expensive even for an attacker with serious hardware.
Migrating old hashes with password_needs_rehash()
Cost parameters that felt appropriately slow a few years ago become comparatively fast as hardware improves, so a hash created with an old cost factor may need to be recomputed with stronger parameters. password_needs_rehash() checks whether a stored hash was created with different (typically weaker) options than the ones you pass it now, without needing to know the password itself. The natural place to call it is right after a successful login, which is the one moment you have the plaintext password in hand.
login.php — transparent rehashing
<?php
if (password_verify($_POST['password'], $user['password_hash'])) {
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($_POST['password'], PASSWORD_DEFAULT);
$update = $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$update->execute([$newHash, $user['id']]);
}
// proceed with login
}This pattern lets an application migrate its entire user base to a stronger algorithm or higher cost factor gradually, one login at a time, with no mass password reset and no downtime.
Never store passwords as plaintext, and never use
md5()orsha1()for them — both are far too fast to resist modern cracking hardware.Use
password_hash($password, PASSWORD_DEFAULT)to create the stored value.Use
password_verify($password, $hash)to check a login attempt — never re-hash and compare strings manually.Call
password_needs_rehash()after a successful login to migrate hashes forward as algorithms and cost parameters improve.Never roll your own hashing scheme, and never invent your own "salt plus fast hash" combination — it will almost always be weaker than the tested algorithms behind
password_hash().