Password Security

Reviewed & published by Brayan K

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.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.
?>

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

// The hash itself is different on every run (that random salt), so print the
// parts that never change. One real one, for the shape:
//   $2y$12$JkG0WOZVRf0eqrIe8iL43ujv/tNHlDNV.JR1PaHUdgqrx5sUBGVcK
echo "Algorithm:   " . password_get_info($hash)["algoName"] . "\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";
?>

Read 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.

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
?>

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
?>

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.

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Password Security

Function / ConstantExampleWhat It Does
password_hashpassword_hash($pw, PASSWORD_ARGON2ID)Hash a password (auto salt)
password_verifypassword_verify($input, $hash)Check input against stored hash
password_needs_rehashpassword_needs_rehash($hash, $algo)Is the hash below today's standard?
hash_equalshash_equals($expected, $given)Constant-time token compare
PASSWORD_BCRYPT["cost" => 12]Bcrypt, tune with cost
PASSWORD_ARGON2ID["memory_cost" => 65536]Argon2id, memory-hard (best)

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
?>

🎉 Lesson Complete!

Practice quiz

Why are md5() and sha1() unsafe for storing passwords?

  • They are too slow to compute
  • They cannot hash strings longer than 8 characters
  • They are fast, unsalted, and deterministic, so attackers reverse leaks with rainbow tables
  • They require an internet connection

Answer: They are fast, unsalted, and deterministic, so attackers reverse leaks with rainbow tables. md5/sha1 are fast checksum functions: a GPU tries billions of guesses per second, and because they're unsalted the same password always hashes the same.

Which two functions are the correct way to store and check a password in PHP?

  • password_hash() and password_verify()
  • md5() and strcmp()
  • encrypt() and decrypt()
  • hash() and hash_equals()

Answer: password_hash() and password_verify(). password_hash() creates a slow, salted hash to store; password_verify() checks a typed password against that stored hash.

Do you need to generate and store a salt yourself when using password_hash()?

  • Yes, you must pass a salt as the third argument
  • Yes, store the salt in a separate column
  • Only when using Argon2id
  • No — it generates a random salt and stores it inside the hash automatically

Answer: No — it generates a random salt and stores it inside the hash automatically. password_hash() generates a secure random salt and bakes it into the hash, which is why the same password hashes to two different strings.

Why does hashing the same password twice with password_hash() give two different strings?

  • The function is broken
  • Each call uses a different random salt
  • It alternates between bcrypt and Argon2
  • It includes the current timestamp as the hash

Answer: Each call uses a different random salt. A unique random salt per hash makes identical passwords look completely different in storage, defeating rainbow tables.

For PASSWORD_BCRYPT, what does increasing the cost by 1 do?

  • Doubles the work (time) required to compute the hash
  • Halves the hashing time
  • Adds one more salt
  • Has no measurable effect

Answer: Doubles the work (time) required to compute the hash. Each +1 to the bcrypt cost doubles the work; 10–12 is a sensible default — slow for attackers, fast enough for login.

What makes PASSWORD_ARGON2ID stronger than bcrypt against modern cracking?

  • It is faster
  • It never needs a salt
  • It is memory-hard, defeating cheap GPU/ASIC farms
  • It produces a shorter hash

Answer: It is memory-hard, defeating cheap GPU/ASIC farms. Argon2id is memory-hard, so it resists the cheap GPU and ASIC farms that bcrypt is more vulnerable to.

When should you call password_needs_rehash() and rehash a password?

  • On every failed login
  • After a successful verify, to silently upgrade a hash below today's standard
  • Before checking the password
  • Only once a year on a schedule

Answer: After a successful verify, to silently upgrade a hash below today's standard. Only rehash after a successful password_verify(); needs_rehash() flags a below-standard hash so you upgrade it without a password reset.

Why use hash_equals() instead of == when comparing a reset token you check yourself?

  • It is faster than ==
  • It hashes the token first
  • It is required for password_verify()
  • It compares in constant time, preventing timing attacks

Answer: It compares in constant time, preventing timing attacks. A normal == returns as soon as two bytes differ, leaking timing info; hash_equals() always takes the same time, blocking timing attacks.

Do you need hash_equals() to compare a password against its stored hash?

  • Yes, always wrap password_verify() in hash_equals()
  • No — password_verify() is already constant-time
  • Only for Argon2id hashes
  • Only when the password is over 72 bytes

Answer: No — password_verify() is already constant-time. password_verify() is already constant-time, so you don't need hash_equals() for passwords — only for tokens you compare by hand.

How does the Have I Been Pwned range API let you check a password without sending it?

  • It encrypts the password before sending it
  • It sends the password over HTTPS so it is safe
  • You send only the first 5 chars of the SHA-1 (k-anonymity) and match the rest locally
  • It only works with bcrypt hashes

Answer: You send only the first 5 chars of the SHA-1 (k-anonymity) and match the rest locally. Using k-anonymity you send just the first 5 hex chars of the SHA-1; the API returns matching suffixes and you check locally, so the password never leaves your server.

Continue this course

Frequently asked questions

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.

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.

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).

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.

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.

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.