PHPPassword Hashing (password_hash)

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.

Vulnerable — do not use
storing $hash = md5($password) or $hash = sha1($password) as a "hash" of a password provides almost no real protection against a determined attacker with the leaked table, because both algorithms are far too fast to resist large-scale guessing.

registration.php (vulnerable)

PHP
<?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
<?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
<?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
<?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
<?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() or sha1() 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().

Column width matters
A bcrypt hash is 60 characters, but Argon2id hashes are longer and their length can vary with the chosen parameters. A `VARCHAR(255)` column comfortably fits any current or likely future `password_hash()` output, so it is worth sizing the column generously up front rather than truncating a hash later.
Tip
Rate-limit login attempts per account and per IP address alongside password hashing. A slow hash function makes each individual guess expensive, but it does not stop an attacker from trying a modest number of common passwords against many accounts — that requires a separate defense, covered on the security best practices page.