Lesson 25 • Advanced
Password Security 🔑
By the end of this lesson you'll store passwords the way every secure app does: hashed with password_hash(), checked with password_verify(), upgraded over time, and shielded by constant-time comparison, rate limiting, and breach checks.
What You'll Learn in This Lesson
- Explain why plain text, MD5, SHA1, and encryption are all unsafe for passwords
- Hash and check passwords with password_hash() and password_verify()
- Choose between PASSWORD_BCRYPT and PASSWORD_ARGON2ID and tune the cost factor
- Silently upgrade old hashes on login with password_needs_rehash()
- Compare secret tokens safely with the constant-time hash_equals()
- Add rate limiting and reject breached passwords with Have I Been Pwned
php file.php. The Output panel under each example shows what to expect.1️⃣ Why Plain Text, MD5 & SHA1 Are a Disaster
Sooner or later, databases leak. The whole game of password security is making the stored value useless to whoever steals it. Storing the password in plain text obviously fails — the attacker reads it straight off. Encrypting it fails too, because encryption is reversible by design: whoever steals the database usually steals the key with it. And MD5/SHA1 — the classic "I hashed it!" mistake — fail because they are fast checksum functions, not password functions: a modern GPU tries billions of guesses per second, and because they're unsalted and deterministic, the same password always yields the same hash, so attackers reverse leaks instantly with precomputed rainbow tables.
<?php
// === NEVER do any of these ===
$password = "hunter2";
// ❌ 1. Plain text — a database leak hands the attacker every account.
$stored = $password;
// ❌ 2. md5 / sha1 — these are FAST hashes built for checksums, not secrets.
// A modern GPU tries billions of guesses per second against them.
echo "md5: " . md5($password) . "\n"; // always the same 32-char string
echo "sha1: " . sha1($password) . "\n"; // always the same 40-char string
// The killer problem: md5/sha1 are deterministic and unsalted, so the SAME
// password always gives the SAME hash. Attackers precompute giant lookup
// tables ("rainbow tables") and reverse millions of leaked hashes instantly.
// ❌ 3. "Encryption" — encryption is reversible by design. A password should
// NEVER be recoverable. If you can decrypt it, so can whoever steals the key.
?>md5: f3bbbd66a63d4bf1747940578ec3d0103
sha1: f3bbbd66a63d4bf1747940578ec3d010 (illustrative — same input = same output)A hash is a one-way function: easy to compute forwards, infeasible to reverse. The right hash for passwords is also deliberately slow and salted — the exact opposite of MD5. That's what PHP's password_* functions give you.
2️⃣ The Right Way: password_hash() & password_verify()
PHP gives you exactly two functions for the whole job. password_hash() turns a password into a slow, salted, self-describing hash you save in your database. password_verify() takes the password a user typed at login plus that stored hash and tells you true or false. You never write the comparison yourself, and — crucially — the salt is automatic: it's generated randomly and stored inside the hash, which is why the same password hashes to two different strings.
<?php
// === The ONLY correct way: password_hash() + password_verify() ===
// --- Registration: hash once, store the hash ---
$password = "MyS3cur3P@ss!";
$hash = password_hash($password, PASSWORD_BCRYPT); // slow + salted, on purpose
echo "Stored hash: " . $hash . "\n";
echo "Length: " . strlen($hash) . " chars\n\n"; // 60 for bcrypt
// password_hash() bakes a RANDOM salt into every hash automatically, so the
// SAME password hashed twice produces TWO DIFFERENT strings — this is what
// makes rainbow tables useless.
$again = password_hash($password, PASSWORD_BCRYPT);
echo "Same password, two hashes equal? ";
var_export($hash === $again); // false — different salts each time
echo "\n\n";
// --- Login: verify the typed password against the stored hash ---
// You do NOT re-hash and compare yourself. password_verify() re-extracts the
// salt from the stored hash, hashes the input the same way, and compares.
echo "Correct password: ";
var_export(password_verify("MyS3cur3P@ss!", $hash)); // true
echo "\nWrong password: ";
var_export(password_verify("letmein", $hash)); // false
echo "\n";
?>Stored hash: $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
Length: 60 chars
Same password, two hashes equal? false
Correct password: true
Wrong password: falseRead that "two hashes equal? false" line again — it's the heart of why this is secure. Each hash carries its own random salt, so identical passwords look completely different in storage, and password_verify() still matches because it reads the salt back out of the stored hash for you.
3️⃣ Algorithms, the Cost Factor & Rehashing
You also pick how hard the hash is to compute. With PASSWORD_BCRYPT the cost sets the number of rounds — each +1 doubles the work, and 10–12 is a sensible 2026 default. PASSWORD_ARGON2ID is the modern recommendation: it's memory-hard, so it defeats the cheap GPU and ASIC farms that bcrypt struggles against. Because hardware keeps getting faster, you raise these costs over time — and password_needs_rehash() lets you upgrade a user's stored hash silently, right after they log in, with no password reset.
<?php
// === Choosing an algorithm + tuning the work factor ===
$password = "correct-horse-battery-staple";
// PASSWORD_BCRYPT: the "cost" is how many rounds of work. Each +1 DOUBLES the
// time. 10–12 is a sensible 2026 default — slow for an attacker, fine for you.
$bcrypt = password_hash($password, PASSWORD_BCRYPT, ["cost" => 12]);
echo "bcrypt prefix: " . substr($bcrypt, 0, 7) . "\n"; // $2y$12$
// PASSWORD_ARGON2ID: the modern winner. It is MEMORY-hard, so it defeats the
// cheap GPU/ASIC farms that crack bcrypt. Tune memory, time, and threads.
$argon = password_hash($password, PASSWORD_ARGON2ID, [
"memory_cost" => 65536, // 64 MB of RAM per hash
"time_cost" => 4, // 4 passes over that memory
"threads" => 2, // parallel lanes
]);
echo "argon2 prefix: " . substr($argon, 0, 9) . "\n\n"; // $argon2id
// === password_needs_rehash(): future-proofing on login ===
// Hardware gets faster, so you raise the cost over time. After a user logs in
// successfully you check whether their stored hash is below today's standard,
// and if so, silently upgrade it — the user never notices.
$oldHash = password_hash($password, PASSWORD_BCRYPT, ["cost" => 8]); // legacy
if (password_verify($password, $oldHash)) {
if (password_needs_rehash($oldHash, PASSWORD_BCRYPT, ["cost" => 12])) {
$newHash = password_hash($password, PASSWORD_BCRYPT, ["cost" => 12]);
echo "Old hash was below standard -> rehashed to cost 12\n";
// saveHashToDatabase($userId, $newHash); // persist the upgrade
}
}
?>bcrypt prefix: $2y$12$
argon2 prefix: $argon2id
Old hash was below standard -> rehashed to cost 12PASSWORD_ARGON2ID needs a PHP build compiled with Argon2 support; if it's missing, swap it for PASSWORD_DEFAULT.4️⃣ Your Turn: Register & Log In
Time to wire it up yourself. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel. This is the exact register-then-login flow you'll write in every real app.
<?php
// 🎯 YOUR TURN — wire up registration and login. Fill each ___ , then run it.
$password = "Tr0ub4dor&3";
// 1) Hash the password for storage (use the Argon2id algorithm constant)
$hash = password_hash($password, ___); // 👉 use PASSWORD_ARGON2ID
// 2) Simulate the right password at login
$loginOk = password_verify("Tr0ub4dor&3", $hash);
// 3) Simulate a WRONG password at login
$loginBad = password_verify(___, $hash); // 👉 pass any wrong string, e.g. "nope"
echo "Right password verifies? "; var_export($loginOk); echo "\n";
echo "Wrong password verifies? "; var_export($loginBad); echo "\n";
// ✅ Expected output:
// Right password verifies? true
// Wrong password verifies? false
?>Right password verifies? true
Wrong password verifies? false___ with PASSWORD_ARGON2ID and the second with any wrong password in quotes, then run it. You should see true then false.One more. This time you'll upgrade a weak legacy hash on login — the everyday job of password_needs_rehash().
<?php
// 🎯 YOUR TURN — upgrade a weak old hash on login. Fill each ___ , then run it.
$password = "summer-2019-account";
$oldHash = password_hash($password, PASSWORD_BCRYPT, ["cost" => 8]); // legacy
// Only ever rehash AFTER a successful verify — never rehash a wrong password.
if (password_verify($password, $oldHash)) {
// 1) Ask whether the stored hash is below today's standard (cost 12)
$needs = password_needs_rehash($oldHash, PASSWORD_BCRYPT, [___]); // 👉 "cost" => 12
if ($needs) {
// 2) Create the stronger hash to save back to the database
$newHash = password_hash($password, PASSWORD_BCRYPT, ["cost" => 12]);
echo "Upgraded? "; var_export($newHash !== $oldHash); echo "\n";
}
}
// ✅ Expected output:
// Upgraded? true
?>Upgraded? true___ with "cost" => 12 so the old cost-8 hash is flagged for upgrade. The output should be Upgraded? true.5️⃣ Defences Around the Hash
Strong hashing protects a leaked database, but you also have to protect the live login form. Three more tools do that. hash_equals() compares secret strings (reset tokens, API keys) in constant time, so an attacker can't time your == to guess a token byte by byte — a timing attack. Rate limiting caps how many guesses each account gets, killing online brute force. And checking new passwords against the Have I Been Pwned breach database (using k-anonymity, so the password never leaves your server) stops users picking a password attackers already have.
<?php
// === Three defences AROUND the hash ===
// 1) hash_equals(): constant-time comparison for tokens you compare yourself.
// A normal === on secrets (password-reset tokens, API keys, HMAC signatures)
// can leak info: it returns the moment two bytes differ, so an attacker can
// measure timing to guess a token byte by byte. hash_equals() always takes
// the same time regardless of where the mismatch is.
$expected = "a1b2c3d4e5f6"; // the real reset token from your database
$provided = $_GET["token"] ?? ""; // whatever the user sent
if (hash_equals($expected, $provided)) { // ✅ timing-safe
echo "Token valid\n";
} else {
echo "Token invalid\n";
}
// (For passwords you never need this — password_verify() is already constant-time.)
// 2) Rate limiting: cap how many guesses an attacker gets per account.
$maxAttempts = 5;
$attempts = 6; // pretend we counted this many recent failures for this user
if ($attempts >= $maxAttempts) {
echo "Too many attempts — try again in 15 minutes\n"; // block, don't verify
}
// 3) Reject known-breached passwords (Have I Been Pwned, k-anonymity).
// You send only the FIRST 5 chars of the SHA-1, never the password itself,
// then check the returned list locally.
$sha1 = strtoupper(sha1("password123")); // hash the candidate
$prefix = substr($sha1, 0, 5); // send only these 5 chars
$suffix = substr($sha1, 5); // match this locally
echo "Would query: api.pwnedpasswords.com/range/$prefix\n";
echo "Then check the response for suffix $suffix\n";
?>Token invalid
Too many attempts — try again in 15 minutes
Would query: api.pwnedpasswords.com/range/CBFDA
Then check the response for suffix C4C4E72... (if present, the password is breached)file_get_contents() or cURL.Common Errors (and the fix)
- Using
md5()orsha1()"to be safe" — these are fast, unsalted checksum functions, crackable at billions of guesses per second. There is no safe way to use them for passwords. Always usepassword_hash()/password_verify()instead. - Rolling your own "salt + hash" — e.g.
sha1($salt . $password). Home-made schemes almost always have a subtle flaw, and you don't need one:password_hash()already salts and stretches correctly. Never reinvent this. - Comparing hashes with
==or===— for the password itself this is wrong because each hash has a different salt (they'll never match). Usepassword_verify(). For reset tokens you compare yourself, usehash_equals()to avoid timing attacks. - Never upgrading old hashes — if you set a cost once in 2020 and never raise it, your hashes age into weakness. Call
password_needs_rehash()after every successful login and re-store the hash when it returnstrue. - Different errors for "no such email" vs "wrong password" — that tells an attacker which emails are registered. Return one message for both: "Invalid email or password."
Pro Tips
- 💡 Store the hash in a column at least 255 chars wide. Bcrypt is 60 chars today, but Argon2 is longer and algorithms evolve — give yourself headroom so an upgrade never truncates.
- 💡 Don't cap password length. Long passphrases are the strongest passwords; the only limit is bcrypt's 72-byte ceiling (Argon2 has none), and that's not a reason to block long input.
- 💡 Aim for ~0.1–0.5s per hash on your server. Benchmark
password_hash()and pick the highest cost your login latency can absorb.
📋 Quick Reference — Password Security
| Function / Constant | Example | What It Does |
|---|---|---|
| password_hash | password_hash($pw, PASSWORD_ARGON2ID) | Hash a password (auto salt) |
| password_verify | password_verify($input, $hash) | Check input against stored hash |
| password_needs_rehash | password_needs_rehash($hash, $algo) | Is the hash below today's standard? |
| hash_equals | hash_equals($expected, $given) | Constant-time token compare |
| PASSWORD_BCRYPT | ["cost" => 12] | Bcrypt, tune with cost |
| PASSWORD_ARGON2ID | ["memory_cost" => 65536] | Argon2id, memory-hard (best) |
Frequently Asked Questions
Q: Why can't I just use md5() or sha1() for passwords?
Because they are fast, unsalted, and deterministic — the opposite of what password storage needs. A modern GPU tries billions of md5/sha1 guesses per second, and because the same password always produces the same hash, attackers reverse millions of leaked hashes instantly using precomputed rainbow tables. password_hash() is deliberately slow and adds a unique random salt to every hash, so brute force becomes impractical and rainbow tables are useless.
Q: Do I need to add a salt myself?
No — and you shouldn't try. password_hash() generates a cryptographically secure random salt for you and stores it inside the resulting hash string. That is why hashing the same password twice gives two different outputs. password_verify() reads the salt back out of the stored hash automatically, so you never store, manage, or compare salts by hand.
Q: Should I use PASSWORD_BCRYPT or PASSWORD_ARGON2ID?
Argon2id is the stronger choice when your PHP build supports it, because it is memory-hard and defeats the cheap GPU and ASIC cracking farms that bcrypt is vulnerable to. Bcrypt is still perfectly secure, more widely available, and a fine default — note it silently truncates passwords at 72 bytes, while Argon2id has no length limit. If unsure, PASSWORD_DEFAULT tracks PHP's current recommendation (bcrypt today).
Q: What is the cost factor and what should I set it to?
The cost (bcrypt) or memory_cost/time_cost (Argon2) controls how much work each hash takes. For bcrypt, every +1 to the cost doubles the time; a cost of 10 to 12 is a sensible 2026 default — slow enough to frustrate attackers, fast enough that your login feels instant. Aim for a hashing time of roughly 0.1 to 0.5 seconds on your server, and raise it over the years as hardware speeds up.
Q: When do I need hash_equals() instead of ==?
Use hash_equals() whenever you compare a secret string yourself — password-reset tokens, API keys, or HMAC signatures. A normal == or === returns as soon as two characters differ, so its run time leaks how much of the secret was correct, letting an attacker guess it byte by byte (a timing attack). hash_equals() always takes the same time. You do not need it for passwords, because password_verify() is already constant-time.
Q: How do I check if a password has been in a data breach?
Use the Have I Been Pwned 'Pwned Passwords' range API, which uses k-anonymity so you never send the password itself. Take the SHA-1 of the candidate password, send only the first 5 hex characters to api.pwnedpasswords.com/range/ABCDE, and the API returns every breached hash suffix that shares that prefix. You then check locally whether the rest of your hash is in that list — if it is, reject the password and ask the user to pick another.
Mini-Challenge: A Safe Login Flow
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the hash-then-verify loop every real authentication system runs.
<?php
// 🎯 MINI-CHALLENGE: A safe register-then-login flow.
// No code is filled in — work from the steps below, then run it.
//
// 1. Put a password in a variable, e.g. $password = "Pa55w.rd!";
// 2. REGISTER: $hash = password_hash($password, PASSWORD_ARGON2ID);
// 3. LOGIN (good): echo whether password_verify($password, $hash) is true
// 4. LOGIN (bad): echo whether password_verify("guessing", $hash) is true
// 5. Print the stored $hash so you can see it is NOT the plain password
//
// Tip: use var_export(...) to print true / false, and "\n" for new lines.
//
// ✅ Expected output (your hash will differ — it is random):
// Good login: true
// Bad login: false
// Stored: $argon2id$v=19$m=65536,t=4,p=1$....
// your code here
?>true, the bad one false.🎉 Lesson Complete!
- ✅ Never store passwords as plain text, encryption, or fast hashes like MD5/SHA1
- ✅
password_hash()hashes with an automatic random salt;password_verify()checks the login - ✅ Pick
PASSWORD_ARGON2ID(memory-hard) orPASSWORD_BCRYPT, and tune the cost - ✅
password_needs_rehash()upgrades old hashes silently after a successful login - ✅
hash_equals()compares tokens in constant time to stop timing attacks - ✅ Add rate limiting and reject breached passwords via Have I Been Pwned
- ✅ Next lesson: Advanced Sessions — manage logged-in users with secure, scalable sessions
Sign up for free to track which lessons you've completed and get learning reminders.