Outdated tutorials still show manual salts with md5()
/sha1()
(or even “double hashing”). That’s not just old—it’s weak.
In modern PHP, you don’t invent your own recipe. You use password_hash()
and password_verify()
.
Why?password_hash()
automatically generates a random per-password salt and stores algorithm + parameters + salt + hash in one string. You keep just that one string in your database—no separate salt
column—and verify with password_verify()
.
Table of Contents
TL;DR
- Don’t use
md5()
/sha1()
+ custom salt. - Do use
password_hash()
+password_verify()
. - Prefer Argon2id (memory-hard); fall back to
PASSWORD_DEFAULT
(bcrypt today). - On login, call
password_needs_rehash()
to upgrade old hashes automatically.
What is a salt—and where did it go?
A salt is random data mixed into the password before hashing. It prevents precomputed attacks (like rainbow tables) and ensures identical passwords hash differently.
With password_hash()
, you don’t manage salts yourself. PHP:
- Generates a cryptographically secure salt,
- Picks the right format,
- Embeds algorithm + options + salt + digest in the returned string.
Store that string (e.g., VARCHAR(255)
). Done.
Minimal (works everywhere): PASSWORD_DEFAULT
This is the easiest, future-proof starting point.
// Create & store the hash (signup / change password)
$hash = password_hash($password, PASSWORD_DEFAULT); // bcrypt today
// Save $hash in your DB (VARCHAR(255) is fine)
// Verify (login) + auto-upgrade if PHP’s default changes
if (password_verify($password, $hashFromDb)) {
if (password_needs_rehash($hashFromDb, PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
// store $newHash back to the DB for this user
}
// success: authenticated
} else {
// invalid credentials
}
PASSWORD_DEFAULT
may change to stronger algorithms in future PHP versions. password_needs_rehash()
lets you upgrade seamlessly.
Recommended when available: Argon2id (tuned)
Argon2id is memory-hard, making large-scale cracking more expensive.
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT;
$options = defined('PASSWORD_ARGON2ID')
? ['memory_cost' => 1 << 17, /* ~128 MB */ 'time_cost' => 4, 'threads' => 2]
: [];
$hash = password_hash($password, $algo, $options);
if (password_verify($password, $hashFromDb)) {
if (password_needs_rehash($hashFromDb, $algo, $options)) {
$newHash = password_hash($password, $algo, $options);
// persist $newHash
}
// success
} else {
// invalid
}
How to choose costs:
Start with memory_cost
64–128 MB and time_cost
3–4, threads
2.
Benchmark on your server and adjust to keep sign-in under ~200–300 ms.
Optional: add a pepper (app-level secret)
A pepper is a server-side secret (not stored in the DB) you mix into the password before hashing. It’s optional defense-in-depth; store it in an env/secret manager, not in your codebase.
$peppered = hash_hmac('sha256', $password, $_ENV['PASSWORD_PEPPER'], true);
$hash = password_hash($peppered, $algo, $options);
// Verify
if (password_verify(hash_hmac('sha256', $password, $_ENV['PASSWORD_PEPPER'], true), $hashFromDb)) {
// ...
}
If you use a pepper, plan how you’d rotate it (e.g., accept old + new pepper during a transition).
Migrating legacy hashes (md5/sha1 + salt)
Have old rows like sha256($salt.$password)
or md5($salt.$password)
?
Upgrade on login without forcing resets:
// 1) Try modern hash first
if (password_verify($password, $user['password_hash'])) {
// maybe rehash...
// OK
} else {
// 2) Try legacy check, then upgrade
$legacyOk = false;
if (!empty($user['legacy_salt']) && !empty($user['legacy_hash'])) {
$calc = hash('sha256', $user['legacy_salt'] . $password);
$legacyOk = hash_equals($user['legacy_hash'], $calc);
}
if ($legacyOk) {
// Upgrade to modern hash
$newHash = password_hash($password, $algo, $options);
// UPDATE users SET password_hash=?, legacy_salt=NULL, legacy_hash=NULL WHERE id=?
// proceed as logged in
} else {
// invalid
}
}
After a sensible window (once most users have logged in), drop the legacy columns.
What to store in the database
- Only the single string returned by
password_hash()
(e.g.,VARCHAR(255)
). - Do not store a separate
salt
column. - Consider
password_changed_at
(timestamp) for security policies.
Secure password reset (bonus)
If your article touches resets, use single-use, short-lived tokens:
- Generate a random token:
bin2hex(random_bytes(32))
. - Store only the hash of the token in DB (
hash('sha256', $token)
). - Email the raw token in a link.
- On submit, hash and compare with
hash_equals()
. - Enforce expiry (e.g., 30 minutes) and invalidate after use.
Common pitfalls (and fixes)
- “Where is the salt?” → Inside the hash string; PHP handles it.
- Manual salts/double hashing → Replace with
password_hash()
/password_verify()
. - No auto-upgrade → Always call
password_needs_rehash()
after a successful login. - Weak cost settings → Benchmark and tune (
Argon2id
preferred). - Leaky errors → Don’t reveal which field failed; log server-side only.
- No rate limiting → Add login throttling (e.g., IP/user delay, CAPTCHA after several failures).
- No HTTPS → Always use TLS in production.
Quick Checklist
- Use
password_hash()
+password_verify()
. - Prefer
PASSWORD_ARGON2ID
(with tuned options); otherwise usePASSWORD_DEFAULT
(bcrypt). - Tune costs and benchmark on your server (e.g., Argon2id ~64–128 MB
memory_cost
,time_cost
3–4,threads
2). - After successful login, call
password_needs_rehash()
and update the stored hash if needed. - Store only the hash string (e.g.,
VARCHAR(255)
); no separate salt column. - Plan migration for legacy hashes (md5/sha1+salt) and upgrade on login.
- Consider an application “pepper” (and a rotation plan) if your threat model warrants it.
- Add login rate limiting and always use HTTPS in production.
Conclusion
You don’t need to hand-craft salts—or chain md5()
/sha1()
—to “strengthen” passwords.
In 2025, the secure, maintainable approach is:
password_hash()
→ store result → password_verify()
→ password_needs_rehash()
when costs improve.
That gives you random salts per user, modern algorithms, and an easy upgrade path as PHP evolves—without rewriting your security every few years.
Good job. I’m definitely going to bookmark you!