PHP Password Hashing
Storing a user’s password as plain text, or even as a simple MD5 hash, is one of the most damaging mistakes in web development. If a database is ever leaked, weakly hashed passwords let attackers log in as your users almost instantly, and because people reuse passwords across sites, the damage spreads far beyond your own application. PHP solves this with a purpose-built password hashing API, password_hash(), password_verify(), and password_needs_rehash(), that handles salting, algorithm selection, and future-proofing for you.
Overview / How It Works
PHP’s password hashing functions (available since PHP 5.5) are a safe wrapper around adaptive hashing algorithms, by default bcrypt, based on the Blowfish cipher. Unlike a general-purpose hash function such as md5() or sha1(), which is designed to be as fast as possible, bcrypt is deliberately slow and tunable. That is the entire point: a fast hash lets an attacker who steals your database test billions of password guesses per second on modern GPU hardware. A slow, adaptive hash like bcrypt can be tuned so each guess costs a meaningful fraction of a second, turning an offline brute-force attack from hours into centuries.
Every call to password_hash() generates a cryptographically random salt automatically and embeds it directly inside the returned hash string, along with the algorithm identifier and the cost factor. That single string is everything you need to store, you never generate or manage the salt yourself, and you never store it in a separate column. Because the salt is random per password, two users with an identical password get completely different hashes, which defeats precomputed ‘rainbow table’ attacks entirely.
Internally, when you call password_verify($password, $hash), PHP reads the algorithm and cost/salt parameters back out of the stored hash string, re-hashes the candidate password using those exact same parameters, and compares the result to the stored hash using a constant-time comparison. This constant-time comparison matters: a naive === or == string comparison can leak timing information that helps an attacker guess a hash byte by byte. You should never implement the comparison yourself, always let password_verify() do it.
PASSWORD_DEFAULT currently maps to bcrypt (PASSWORD_BCRYPT), but PHP intentionally leaves room to change the default to a stronger algorithm, such as Argon2 (PASSWORD_ARGON2I or PASSWORD_ARGON2ID), in a future release. Using PASSWORD_DEFAULT instead of hardcoding PASSWORD_BCRYPT means your application automatically benefits from that upgrade the next time PHP is updated, as long as you also use password_needs_rehash() to migrate old hashes when a user logs in.
Syntax
<?php
string password_hash(string $password, string|int|null $algo, array $options = [])
bool password_verify(string $password, string $hash)
bool password_needs_rehash(string $hash, string|int|null $algo, array $options = [])
array password_get_info(string $hash)
| Part | Meaning |
|---|---|
$password |
The plain-text password supplied by the user. Never pre-hash it yourself before passing it in. |
$algo |
An algorithm constant: PASSWORD_DEFAULT, PASSWORD_BCRYPT, PASSWORD_ARGON2I, or PASSWORD_ARGON2ID. Almost always use PASSWORD_DEFAULT. |
$options |
An associative array of tuning parameters. For bcrypt: ['cost' => 10] (range 4-31, default 10). For Argon2: memory_cost, time_cost, threads. |
$hash |
The full hash string previously returned by password_hash(), stored exactly as-is (typically 60 characters for bcrypt). |
Examples
Example 1: Hashing and verifying a password
<?php
$password = 'CorrectHorseBatteryStaple!42';
$hash = password_hash($password, PASSWORD_DEFAULT);
echo "Algorithm prefix: " . substr($hash, 0, 4) . PHP_EOL;
echo "Hash length: " . strlen($hash) . PHP_EOL;
if (password_verify($password, $hash)) {
echo "Password is valid!" . PHP_EOL;
} else {
echo "Invalid password." . PHP_EOL;
}
if (password_verify('wrong-password', $hash)) {
echo "Password is valid!" . PHP_EOL;
} else {
echo "Invalid password." . PHP_EOL;
}
Output:
Algorithm prefix: $2y$
Hash length: 60
Password is valid!
Invalid password.
The stored hash always begins with $2y$ for bcrypt and is always exactly 60 characters long, no matter how long the original password was. This length is deterministic even though the salt inside the hash is random every time the script runs, which is why the actual hash text is different on every execution but its prefix and length are not. password_verify() correctly accepts the real password and rejects the wrong one.
Example 2: A registration and login flow
<?php
final class UserStore
{
private array $users = [];
public function register(string $username, string $password): void
{
$this->users[$username] = password_hash($password, PASSWORD_DEFAULT);
}
public function attemptLogin(string $username, string $password): bool
{
if (!isset($this->users[$username])) {
return false;
}
return password_verify($password, $this->users[$username]);
}
}
$store = new UserStore();
$store->register('alice', 'Tr0ub4dor&3');
$attempts = [
['alice', 'Tr0ub4dor&3'],
['alice', 'wrong-guess'],
['bob', 'anything'],
];
foreach ($attempts as [$username, $password]) {
$result = $store->attemptLogin($username, $password) ? 'success' : 'failure';
echo "$username: $result" . PHP_EOL;
}
Output:
alice: success
alice: failure
bob: failure
Notice that only the hash is ever stored in $users, never the plain-text password. The class doesn’t need any special logic to compare passwords securely, it just delegates entirely to password_hash() and password_verify(). A login attempt for a username that was never registered safely returns false instead of throwing an error.
Example 3: Upgrading the cost factor with password_needs_rehash()
<?php
function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
}
function verifyAndUpgrade(string $password, string &$storedHash): bool
{
if (!password_verify($password, $storedHash)) {
return false;
}
if (password_needs_rehash($storedHash, PASSWORD_BCRYPT, ['cost' => 12])) {
$storedHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
echo "Hash upgraded to cost 12." . PHP_EOL;
}
return true;
}
$hash = hashPassword('S3cur3P@ss');
echo "Original cost prefix: " . substr($hash, 4, 3) . PHP_EOL;
$ok = verifyAndUpgrade('S3cur3P@ss', $hash);
echo "Login result: " . ($ok ? 'valid' : 'invalid') . PHP_EOL;
echo "New cost prefix: " . substr($hash, 4, 3) . PHP_EOL;
Output:
Original cost prefix: 10$
Hash upgraded to cost 12.
Login result: valid
New cost prefix: 12$
This pattern is exactly how production applications keep old password hashes current as hardware gets faster: on every successful login, check password_needs_rehash() against your current preferred cost or algorithm, and if it returns true, hash the password the user just typed (which you have in plain text at that moment) and overwrite the stored hash. Users never notice, but every login gradually upgrades your entire password table.
How It Works Step by Step / Under the Hood
- Registration: the user submits a plain-text password. You call
password_hash($password, PASSWORD_DEFAULT). PHP generates a random 128-bit salt using a cryptographically secure random source, runs the bcrypt algorithm for 2^cost rounds, and packs the algorithm identifier, cost, salt, and resulting hash into one 60-character string like$2y$10$N9qo8uLOickgx2ZMRZoMy.... - Storage: you save that entire string, unmodified, in a database column sized for at least 255 characters (bcrypt is 60, but Argon2 hashes can be longer, so 255 keeps you future-proof).
- Login: the user submits a plain-text password again. You fetch the stored hash string and call
password_verify($password, $hash). - Re-derivation: PHP parses the algorithm, cost, and salt straight out of the stored hash string, then re-runs the same hashing algorithm on the submitted password using those exact parameters, producing a new hash to compare.
- Constant-time comparison: the two hash strings are compared using a timing-safe routine so that the amount of time the comparison takes does not reveal how many leading bytes matched.
- Optional rehash check: after a successful verify, you can call
password_needs_rehash()to see whether the stored hash was created with weaker parameters than your current policy, and if so, replace it.
Common Mistakes
Mistake 1: Using md5() or sha1() and a loose comparison
<?php
$password = $_POST['password'] ?? '';
$hash = md5($password);
if ($hash == $storedHash) {
echo "Login successful!" . PHP_EOL;
} else {
echo "Invalid credentials." . PHP_EOL;
}
This is broken in two independent ways. First, md5() and sha1() are fast general-purpose hashes designed for checksums, not passwords, an attacker with a leaked database can test tens of billions of guesses per second against them. Second, the loose == comparison is vulnerable to PHP’s type-juggling: certain hash strings that look like "0e1234..." are interpreted as scientific notation and can compare equal to other numeric-looking strings, letting an attacker bypass the check entirely without knowing the password.
<?php
session_start();
$password = $_POST['password'] ?? '';
$storedHash = $_SESSION['password_hash'] ?? '';
if ($storedHash !== '' && password_verify($password, $storedHash)) {
echo "Login successful!" . PHP_EOL;
} else {
echo "Invalid credentials." . PHP_EOL;
}
The fix uses password_hash() at registration time and password_verify() at login time, which performs a strict, constant-time comparison internally and is immune to type-juggling because it never uses ==.
Mistake 2: Truncating the storage column
<?php
function saveHash(PDO $pdo, int $userId, string $password): void
{
$hash = substr(password_hash($password, PASSWORD_DEFAULT), 0, 32);
$stmt = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
$stmt->execute(['hash' => $hash, 'id' => $userId]);
}
This mistake usually comes from legacy schemas designed for md5(), which produces exactly 32 hex characters. A bcrypt hash is 60 characters, so truncating it to 32 (either explicitly, as above, or implicitly via a VARCHAR(32) column that silently cuts off the rest) destroys the salt and hash data needed to verify the password. Every single login for every user will fail, or worse, many truncated hashes can collide, letting unrelated passwords verify successfully.
<?php
function saveHash(PDO $pdo, int $userId, string $password): void
{
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE users SET password_hash = :hash WHERE id = :id');
$stmt->execute(['hash' => $hash, 'id' => $userId]);
}
Store the full, unmodified string in a column of at least VARCHAR(255). Never call substr(), trim(), or any transformation on a password hash before saving or comparing it.
Best Practices
- Always use
PASSWORD_DEFAULTrather than hardcodingPASSWORD_BCRYPT, so your app automatically adopts stronger algorithms as PHP evolves. - Never write your own hashing, salting, or comparison logic, the built-in functions already handle salts and timing-safe comparison correctly.
- Store the full hash string in a
VARCHAR(255)(or larger) column, never truncate or transform it. - Call
password_needs_rehash()after every successful login and upgrade the stored hash when it returnstrue, this is the only time you have the plain-text password available to rehash it. - Enforce a reasonable maximum password length (for example 72 bytes, bcrypt’s practical limit) before hashing, but do not impose overly restrictive rules like banning special characters.
- Never log, email, or display a user’s plain-text password anywhere, not even temporarily during debugging.
- Use HTTPS for every page that submits a password, hashing protects your database, not the network in transit.
- Rate-limit and add delays or lockouts to login endpoints to slow down online brute-force and credential-stuffing attempts, since bcrypt only protects against offline attacks on a stolen database.
Practice Exercises
- Write a function
registerUser(string $username, string $password): arraythat returns an associative array with the username and a bcrypt hash of the password usingPASSWORD_DEFAULT. Then writelogin(array $user, string $password): boolthat verifies a login attempt against that array. - Modify the registration function from Exercise 1 to reject passwords shorter than 8 characters before hashing, returning
nullinstead of hashing when the password is too short. - Write a function that takes an existing bcrypt hash created with
cost => 10and a freshly typed password, and returns a new hash atcost => 12only ifpassword_needs_rehash()says it is needed and the password verifies successfully. What should the function return if verification fails?
Summary
password_hash($password, PASSWORD_DEFAULT)produces a self-contained string with the algorithm, cost, salt, and hash all embedded, safe to store directly in the database.password_verify($password, $hash)is the only correct way to check a login attempt, it re-derives the hash using the stored parameters and compares in constant time.- Bcrypt (the current
PASSWORD_DEFAULT) is deliberately slow and tunable via a cost factor, unlike fast general-purpose hashes such asmd5()orsha1(), which must never be used for passwords. password_needs_rehash()lets you upgrade old hashes to stronger settings transparently whenever a user logs in successfully.- Store hashes in a column of at least 255 characters, never truncate or otherwise transform them.
- Hashing protects a stolen database; it does not replace HTTPS, rate limiting, or general application security.
