Password Hashing — password_hash(), password_verify(), Argon2id, and Rehashing
Store passwords correctly using PHP's built-in password_hash() with the Argon2id algorithm (falling back to bcrypt via PASSWORD_DEFAULT where Argon2id is unavailable), verify logins with password_verify(), understand why the salt never needs to be handled manually, and use password_needs_rehash() to migrate hashes forward as algorithms and cost parameters improve.
A database breach is a matter of when, not if, for any application that exists long enough -- backups leak, credentials get phished, a dependency gets compromised. The single control that determines whether that breach also compromises every user's password on every OTHER site they reused it on is how passwords were stored in the first place. Storing a plaintext password is an unrecoverable mistake the moment the database is read by anyone unauthorized. PHP has shipped a correct, hard-to-misuse password hashing API since 5.5, and there is no good reason to write your own hashing code around it.
Why Plaintext Storage Is Never Acceptable
Storing a password as the user typed it means anyone with read access to that one database table -- an attacker after a breach, a careless backup left in a public bucket, even an overly-trusted internal employee -- instantly has every user's real password, usable not just against your site but against every other site where that user reused the same password (which is extremely common). There is no legitimate reason an application ever needs to recover a user's original password; it only ever needs to check whether a supplied password matches what was set, which a one-way hash accomplishes without ever storing the recoverable original.
A one-way cryptographic hash function takes the password as input and produces a fixed-length output from which the original cannot practically be recovered, but which reliably produces the exact same output for the exact same input. Storing only the hash means a breach exposes hashes, not passwords -- and a properly chosen hashing algorithm makes reversing those hashes back to plausible passwords computationally expensive even at scale.
• Never store a password as typed -- a breach then exposes every user's real password immediately, on this site and any other site they reused it on. • A one-way hash lets the app verify a password without ever storing (or being able to recover) the original. • The goal of a GOOD password hash algorithm is to make reversing it back to the original prohibitively slow, even with attacker hardware. • This rule has no exceptions: not for 'internal tools', not for 'we'll add hashing later', not ever.
password_hash() and Choosing an Algorithm
PHP's password_hash(string $password, string|int $algo) is the correct tool for this: pass the plaintext password and an algorithm constant, and it returns a single string containing the algorithm identifier, cost parameters, the randomly generated salt, and the hash itself -- everything needed to verify it later, in one self-describing value that's safe to store directly in a VARCHAR(255) column. PASSWORD_ARGON2ID selects Argon2id, the current recommended default: a memory-hard algorithm specifically designed to resist GPU/ASIC cracking farms far better than older algorithms, because it deliberately costs a lot of RAM per guess, not just CPU time.
Argon2id requires the Sodium extension (bundled by default since PHP 7.2, but some minimal or stripped-down builds omit it), so a fully portable app should check defined('PASSWORD_ARGON2ID') and fall back to PASSWORD_DEFAULT (bcrypt at the time of writing) when Argon2id isn't compiled in. PASSWORD_DEFAULT is itself a deliberately moving target -- PHP's maintainers update what it points to as consensus about the best available algorithm evolves, which is exactly why hardcoding PASSWORD_BCRYPT forever, instead of periodically revisiting the choice, forgoes that automatic improvement.
Never hand-roll a salt and never call a general-purpose hash function like md5(), sha1(), or even a bare hash('sha256', ...) on a password. Those functions are deliberately FAST -- built for checksums, not for slowing down brute-force guessing -- so an attacker with modern hardware can try billions of candidate passwords per second against them. password_hash() uses algorithms deliberately designed to be slow and memory-hard, which is precisely the property a stolen password hash needs to resist offline cracking.
• password_hash($password, PASSWORD_ARGON2ID) — memory-hard, current best-practice default. • Fall back to PASSWORD_DEFAULT (bcrypt) when Argon2id/Sodium isn't available: defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT. • Never use md5(), sha1(), or a bare hash() on a password -- those are fast general-purpose hashes, the opposite of what password storage needs. • The returned string is self-contained (algorithm + cost + salt + hash) -- store it as-is, no extra bookkeeping columns needed.
The Salt Is Already Handled For You
A salt is random data mixed into the password before hashing so that two users with the identical password get completely different stored hashes, which defeats precomputed 'rainbow table' lookups and stops an attacker from spotting that two accounts share a password just by comparing hash values. password_hash() generates a cryptographically secure random salt automatically on every call and embeds it directly inside the returned string -- there is no separate salt parameter to pass, no salt column to add to the users table, and critically, no reason to ever try to add your own salt on top.
Manually concatenating your own salt before calling password_hash() (password_hash($salt . $password, ...)) does not make things safer -- it just changes what string gets hashed, while the function's own internal salt still does the actual security work. Worse, some manual salting schemes reuse the SAME salt across all users (a static 'pepper' hardcoded in application code), which defeats the entire purpose: unique salts are only protective when each one is unique per password.
• password_hash() generates a fresh, cryptographically random salt automatically, every single call. • The salt lives embedded inside the returned hash string -- no separate storage or handling required. • Never write your own salting logic on top -- it adds complexity without adding security, and risks reusing the same salt for every user if done carelessly. • Because the salt is per-call-random, hashing the same password twice produces two DIFFERENT stored strings -- that's expected and correct, not a bug.
password_verify() and password_needs_rehash()
To check a login attempt, pass the plaintext candidate and the stored hash to password_verify(string $password, string $hash). It extracts the algorithm, cost, and salt from inside the stored hash string, redoes the exact same hashing process on the candidate, and compares the results -- it returns a plain boolean, and the application never needs to know or care which algorithm or cost the hash was created with.
Because PASSWORD_DEFAULT (and recommended cost parameters) change over time as hardware gets faster and cryptographic guidance evolves, a hash created years ago with an older algorithm or a lower cost factor is weaker relative to current attacker capability than one created today. password_needs_rehash(string $hash, string|int $algo) checks whether a given stored hash was created with different (typically weaker/older) settings than the ones currently in use, WITHOUT needing the plaintext password to make that determination.
The standard pattern is to call password_needs_rehash() right after a SUCCESSFUL password_verify() during login -- that is the one moment the application legitimately has the plaintext password in memory. If it returns true, immediately re-hash the plaintext with current settings and overwrite the stored hash, silently upgrading that user's security on their next login with zero disruption and no forced password reset.
• password_verify($plaintext, $storedHash) -- returns bool; extracts algorithm/cost/salt from the hash itself. • password_needs_rehash($storedHash, $currentAlgo) -- true if the hash was made with outdated settings. • Call needs_rehash() right after a successful verify(), while the plaintext is still in memory, and re-save if needed. • This lets algorithm/cost upgrades roll out transparently across your user base over time, with no forced mass password reset.
The Three Core Functions
password_hash() never needs a manual salt argument -- PASSWORD_ARGON2ID and PASSWORD_BCRYPT both generate one internally. password_verify() takes the PLAINTEXT candidate first, the stored hash second -- easy to mix up, double-check argument order. password_needs_rehash() only tells you the hash is outdated; you must still have the plaintext on hand to actually produce a new one.
// Choose the strongest available algorithm, with a safe fallback
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
// Hashing (at registration / password change)
$hash = password_hash($plaintextPassword, $algo);
// $hash is a single self-contained string — store it directly, e.g. in users.password_hash
// Verifying (at login)
$isValid = password_verify($submittedPassword, $hash); // bool
// Detecting an outdated hash (right after a successful verify)
if (password_needs_rehash($hash, $algo)) {
$hash = password_hash($submittedPassword, $algo); // re-hash with current settings
// save the new $hash over the old one
}Code Examples
The plaintext password exists only transiently, in memory, long enough to be hashed. Only the self-contained hash string is ever persisted -- the database's password_hash column never sees the original password. Expected result: $hash example: $argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ...$actualhashbytes...
$hash = password_hash($submitted, $algo);
Common mistake: Logging the raw $_POST array (including the password field) for debugging, or writing it to an error/access log -- this silently defeats hashing entirely by putting the plaintext password somewhere persistent anyway.
password_verify() does the actual authentication check. Only after it succeeds do we check password_needs_rehash() and, if the stored hash used older settings, silently re-hash and save the current plaintext under today's algorithm -- upgrading security with zero user-facing disruption. This pattern means a site that switches its default from bcrypt to Argon2id doesn't need a mass password reset -- every user's hash gets silently upgraded the next time they log in normally.
if (password_needs_rehash($row['password_hash'], $algo)) {
<?php
declare(strict_types=1);
function hashNewPassword(string $plaintextPassword): string
{
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
return password_hash($plaintextPassword, $algo);
}
// At registration:
$submitted = $_POST['password'] ?? '';
if (strlen($submitted) < 8) {
http_response_code(422);
echo json_encode(['error' => 'Password must be at least 8 characters']);
exit;
}
$hash = hashNewPassword($submitted);
// Store ONLY $hash in the database — never $submitted.
Database::query(
'INSERT INTO users (email, password_hash) VALUES (?, ?)',
[$_POST['email'], $hash]
);
// $submitted is never written to disk, logs, or any persistent storage from this point on.<?php
declare(strict_types=1);
function attemptLogin(string $email, string $submittedPassword): bool
{
$row = Database::queryOne(
'SELECT id, password_hash FROM users WHERE email = ?',
[$email]
);
if ($row === null) {
return false; // no such account (kept generic — see the secure-login lesson)
}
if (!password_verify($submittedPassword, $row['password_hash'])) {
return false; // wrong password
}
// Login succeeded — this is the one moment we legitimately hold the plaintext.
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
if (password_needs_rehash($row['password_hash'], $algo)) {
$newHash = password_hash($submittedPassword, $algo);
Database::query('UPDATE users SET password_hash = ? WHERE id = ?', [$newHash, $row['id']]);
}
return true;
}What password_hash() actually stores
Every piece needed to verify later travels inside the single stored string.
password_hash("hunter2", PASSWORD_ARGON2ID)
|
v
$argon2id $ v=19 $ m=65536,t=4,p=1 $ <random salt> $ <hash output>
| | | | |
algorithm version cost params embedded salt the actual hash
(generated fresh
on every call)
-> ONE string. Store it as-is. No separate salt column needed.Important Rules
Never store a password as plaintext, under any circumstance, for any account type.
Use password_hash() with PASSWORD_ARGON2ID when available, falling back to PASSWORD_DEFAULT (bcrypt) otherwise -- never md5/sha1/bare hash() for passwords.
Never hand-roll a salt -- password_hash() generates and embeds one automatically on every call.
Verify logins with password_verify(), never by re-hashing manually and comparing strings.
Call password_needs_rehash() right after a successful verify(), while the plaintext is still available, and re-save the hash if it returns true.
What large breaches reveal about hashing choices
Post-mortems of major credential breaches routinely distinguish between sites that stored bcrypt/Argon2 hashes (where cracking even a modest fraction of passwords took the attacker significant compute time and resources) versus sites still using fast hashes like unsalted MD5 or SHA1 (where the entire password list was often cracked within hours of the breach becoming public). The algorithm choice made years before the breach directly determined how much real damage the breach caused.
Storing passwords with md5($password) or sha1($password) because 'it's still hashed'.
Use password_hash($password, PASSWORD_ARGON2ID ?? PASSWORD_DEFAULT) instead.
md5 and sha1 are fast, general-purpose hash functions built for checksums -- an attacker with modern GPUs can test billions of password guesses per second against them, making a stolen hash trivial to crack. password_hash()'s algorithms are deliberately slow and memory-hard to resist exactly this.
Manually generating a salt with random_bytes(), concatenating it to the password, and storing the salt in a separate database column.
Just call password_hash($password, $algo) and store the single returned string -- the salt is already generated and embedded internally.
This adds bookkeeping complexity for zero security benefit, and risks bugs like reusing the same salt across all users if the salt-generation code is written carelessly -- password_hash() already does this correctly by default.
Why does password_hash() not require the developer to generate or pass a salt manually? A. PHP does not support salting at all B. It generates a cryptographically random salt internally on every call and embeds it inside the returned hash string C. Salts are only needed for encryption, not hashing D. The salt must always be hardcoded as an application secret
B. It generates a cryptographically random salt internally on every call and embeds it inside the returned hash string — password_hash() generates a fresh random salt every time it runs and stores it embedded in the single returned string, so there is no separate salt to manage, store, or pass in.
When is the correct moment to call password_needs_rehash() during a login flow? A. Before checking the password at all B. Immediately after password_verify() succeeds, while the plaintext password is still available C. Only when a user explicitly requests a password change D. It should never be called during login, only during registration
B. Immediately after password_verify() succeeds, while the plaintext password is still available — password_needs_rehash() only tells you a hash is outdated -- actually producing a new hash still requires the plaintext password, which the application only legitimately holds right after a successful password_verify() during login.
Write a function changePassword(int $userId, string $currentPassword, string $newPassword): bool that looks up the user's current password_hash, verifies $currentPassword against it with password_verify(), and only if that succeeds, hashes $newPassword with the Argon2id-or-fallback pattern and updates the database. Return false if the current password doesn't verify.
function changePassword(int $userId, string $currentPassword, string $newPassword): bool { $row = Database::queryOne('SELECT password_hash FROM users WHERE id = ?', [$userId]); if ($row === null || !password_verify($currentPassword, $row['password_hash'])) return false; $algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT; $newHash = password_hash($newPassword, $algo); Database::query('UPDATE users SET password_hash = ? WHERE id = ?', [$newHash, $userId]); return true; }
This is structurally the same as attemptLogin() above, except on success it hashes a DIFFERENT (new) password rather than checking needs_rehash() on the old one.