Skip to main content
    Courses/PHP/Password Security

    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

    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.

    Everything you must NOT do
    <?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.
    ?>
    Output
    md5:  f3bbbd66a63d4bf1747940578ec3d0103
    sha1: f3bbbd66a63d4bf1747940578ec3d010 (illustrative — same input = same output)
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Hash on registration, verify on login
    <?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";
    ?>
    Output
    Stored hash: $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    Length:      60 chars
    
    Same password, two hashes equal? false
    
    Correct password: true
    Wrong password:   false
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Tune the work factor and auto-upgrade old hashes
    <?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
        }
    }
    ?>
    Output
    bcrypt prefix:  $2y$12$
    argon2 prefix:  $argon2id
    
    Old hash was below standard -> rehashed to cost 12
    Real PHP — run it at onecompiler.com/php or locally. PASSWORD_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.

    🎯 Your turn: hash and verify
    <?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
    ?>
    Output
    Right password verifies? true
    Wrong password verifies? false
    Fill the first ___ 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().

    🎯 Your turn: rehash on login
    <?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
    ?>
    Output
    Upgraded? true
    Fill the ___ 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.

    Constant-time compare, rate limit, breach check
    <?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";
    ?>
    Output
    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)
    Real PHP — run it at onecompiler.com/php or locally. The breach step only shows the request to build; the live API lookup needs an HTTP call, which you'd add with file_get_contents() or cURL.

    Common Errors (and the fix)

    • Using md5() or sha1() "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 use password_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). Use password_verify(). For reset tokens you compare yourself, use hash_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 returns true.
    • 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 / 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)

    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.

    🎯 Mini-Challenge: register, then log in right and wrong
    <?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
    ?>
    Hash a password, verify a correct and an incorrect attempt, and print the stored hash. The good login should be 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) or PASSWORD_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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service