Skip to main content
    Courses/PHP/Authentication Deep Dive

    Lesson 23 • Advanced

    Authentication Deep Dive 🔐

    By the end of this lesson you'll be able to register and log in users the secure way — hashing passwords with password_hash, checking them with password_verify, storing users with PDO prepared statements, and keeping people signed in with sessions.

    What You'll Learn in This Lesson

    • Hash passwords safely with password_hash (bcrypt / Argon2) — never plain text
    • Check a login with password_verify, without ever decrypting anything
    • Save users in a database using PDO prepared statements (no SQL injection)
    • Track who is logged in with PHP sessions, and log them out cleanly
    • Add a secure "remember me" cookie and rehash passwords on login
    • Protect private pages so only signed-in users can reach them

    1️⃣ Hashing a Password with password_hash

    The golden rule of authentication: never store a password you can read back. Instead you hash it — run it through a one-way function that turns "hunter2" into a long scrambled string you can't reverse. PHP gives you exactly one function to do this well: password_hash(). It automatically adds a random salt (extra random data) so the same password produces a different hash every time, which kills entire classes of attack. You store that hash in your database; you never store the password itself.

    Hash a password, then verify it
    <?php
    // NEVER store a password as plain text. Hash it one way instead.
    // password_hash() turns a password into a long, salted hash you can store.
    
    $plain = "correct horse battery staple";
    
    // PASSWORD_DEFAULT uses bcrypt today (and upgrades automatically in future PHP).
    $hash = password_hash($plain, PASSWORD_DEFAULT);
    
    echo "Stored hash:\n$hash\n\n";
    
    // The same password hashed TWICE gives two DIFFERENT hashes — each has a random salt.
    $again = password_hash($plain, PASSWORD_DEFAULT);
    echo "Hashed again (different salt):\n$again\n\n";
    
    // To check a login you don't decrypt — you VERIFY with password_verify().
    echo "Right password? " . (password_verify($plain, $hash) ? "yes" : "no") . "\n";
    echo "Wrong password? " . (password_verify("guessing", $hash) ? "yes" : "no") . "\n";
    ?>
    Output
    Stored hash:
    $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
    
    Hashed again (different salt):
    $2y$10$IjZAgcfl7p92ldGxad68LeoN9qo8uLOickgx2ZMRZoMyeMkR3pFq.
    
    Right password? yes
    Wrong password? no
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Notice you never "decrypt" anything. To check a login you call password_verify($typed, $hash): it re-hashes what the user typed using the salt baked into the stored hash and compares the two safely. Match means the password was right.

    2️⃣ Choosing the Algorithm: BCRYPT & Argon2

    PASSWORD_DEFAULT currently means bcrypt, a deliberately slow hash — slowness is a feature, because it makes brute-forcing expensive. You can raise the "cost" to make each guess cost more, or choose Argon2id (PASSWORD_ARGON2ID), the modern "memory-hard" winner of the Password Hashing Competition. Both are excellent; bcrypt is the safe default everywhere, Argon2id is preferred if your PHP build supports it.

    bcrypt cost vs Argon2id
    <?php
    // You can pick the algorithm and tune how slow (= how strong) it is.
    
    $plain = "hunter2";
    
    // bcrypt with a higher "cost" — each step doubles the work an attacker needs.
    $bcrypt = password_hash($plain, PASSWORD_BCRYPT, ["cost" => 12]);
    echo "bcrypt (cost 12): " . substr($bcrypt, 0, 7) . "...\n";
    
    // Argon2id is the modern memory-hard choice (PHP 7.3+ built with Argon2).
    if (defined("PASSWORD_ARGON2ID")) {
        $argon = password_hash($plain, PASSWORD_ARGON2ID);
        echo "argon2id:          " . substr($argon, 0, 9) . "...\n";
    }
    
    // password_get_info() tells you which algorithm a stored hash used.
    $info = password_get_info($bcrypt);
    echo "Algorithm name:   " . $info["algoName"] . "\n";
    ?>
    Output
    bcrypt (cost 12): $2y$12$...
    argon2id:          $argon2id...
    Algorithm name:   bcrypt
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Use PASSWORD_DEFAULT unless you have a reason not to — it lets PHP upgrade the algorithm for you in future versions, and (with the rehash check in section 6) your stored hashes upgrade along with it.

    3️⃣ Registration: Storing Users with PDO

    Registration is two steps: hash the password, then save the user. To save safely you use a prepared statement — you write the SQL with ? placeholders and pass the real values separately, so a malicious email like '; DROP TABLE users; -- is treated as plain data, never as SQL. This is your defence against SQL injection. Gluing user input straight into a query string is the classic beginner mistake; prepared statements make it impossible.

    Register a user (hash + prepared INSERT)
    <?php
    // REGISTRATION: store the HASH (never the password) using a prepared statement.
    
    $pdo = new PDO("sqlite::memory:");           // demo DB; swap for your real DSN
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->exec("CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL
    )");
    
    function registerUser(PDO $pdo, string $email, string $password): string {
        // 1) Hash the plain password — this is the ONLY form we ever store.
        $hash = password_hash($password, PASSWORD_DEFAULT);
    
        // 2) Prepared statement: ? placeholders keep user input OUT of the SQL.
        $stmt = $pdo->prepare(
            "INSERT INTO users (email, password_hash) VALUES (?, ?)"
        );
        $stmt->execute([$email, $hash]);          // values bound safely, no injection
        return "Registered $email (id " . $pdo->lastInsertId() . ")";
    }
    
    echo registerUser($pdo, "ada@example.com", "s3cret-pw") . "\n";
    
    // The row holds a hash, not the password:
    $row = $pdo->query("SELECT email, password_hash FROM users")->fetch(PDO::FETCH_ASSOC);
    echo "Saved email: {$row['email']}\n";
    echo "Saved value is a hash, not the password: "
       . (str_starts_with($row['password_hash'], '$2y$') ? "true" : "false") . "\n";
    ?>
    Output
    Registered ada@example.com (id 1)
    Saved email: ada@example.com
    Saved value is a hash, not the password: true
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    The stored row contains a bcrypt hash (it starts with $2y$), not the password. Even if your whole database leaked tomorrow, attackers still wouldn't have your users' passwords.

    4️⃣ Login: Verify, Then Start a Session

    Logging in means: look up the user by email, password_verify() their password against the stored hash, and if it matches, record that they're logged in. PHP tracks logged-in state with a session — a small server-side store keyed to a cookie. After session_start() you read and write $_SESSION like an array; here you save the user's id. Two security musts: return one generic failure for both "no such user" and "wrong password" (so you don't reveal which emails exist), and call session_regenerate_id(true) on success to stop session fixation (section covered in Common Errors).

    Log a user in
    <?php
    // LOGIN: fetch the user by email, verify the password, then start a session.
    session_start();                              // must come before any output
    
    function login(PDO $pdo, string $email, string $password): bool {
        // Fetch the stored hash for this email (prepared statement again).
        $stmt = $pdo->prepare("SELECT id, password_hash FROM users WHERE email = ?");
        $stmt->execute([$email]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);
    
        // No such user OR wrong password -> same generic failure (don't leak which).
        if (!$user || !password_verify($password, $user['password_hash'])) {
            return false;
        }
    
        // Success: prevent session fixation, then remember who is logged in.
        session_regenerate_id(true);              // new session id on privilege change
        $_SESSION['user_id'] = $user['id'];
        return true;
    }
    
    // (Assume $pdo already has ada@example.com / "s3cret-pw" from registration.)
    echo login($pdo, "ada@example.com", "wrong-pw") ? "logged in\n" : "login failed\n";
    echo login($pdo, "ada@example.com", "s3cret-pw") ? "logged in\n" : "login failed\n";
    echo "Session user_id: " . ($_SESSION['user_id'] ?? "none") . "\n";
    ?>
    Output
    login failed
    logged in
    Session user_id: 1
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    5️⃣ Protecting Pages & Logging Out

    Once login state lives in $_SESSION, protecting a page is easy: at the top, check for $_SESSION['user_id'] and, if it's missing, redirect to the login page and exit immediately — forgetting exit is a real bug, because the protected code below would still run. Logging out is the reverse: empty the session array, expire the session cookie, and destroy the session.

    Guard a page, then log out
    <?php
    // PROTECT A PAGE: anything below this guard only runs for logged-in users.
    session_start();
    
    function requireLogin(): int {
        if (!isset($_SESSION['user_id'])) {
            header("Location: /login.php");       // send guests to the login page
            exit;                                 // STOP — never let protected code run
        }
        return $_SESSION['user_id'];
    }
    
    $userId = requireLogin();
    echo "Welcome back, user #$userId — this is your private dashboard.\n";
    
    // LOGOUT: clear the data, destroy the session, drop the cookie.
    function logout(): void {
        $_SESSION = [];                           // empty the session array
        if (ini_get("session.use_cookies")) {
            setcookie(session_name(), "", time() - 3600, "/");  // expire the cookie
        }
        session_destroy();                        // wipe the server-side session
    }
    ?>
    Output
    Welcome back, user #1 — this is your private dashboard.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Now you try. The script below registers and logs in a user, but two function calls are missing. Fill in each ___ using the 👉 hint, then run it and check it against the Output panel.

    🎯 Your turn: hash and verify
    <?php
    // 🎯 YOUR TURN — register, then log in. Fill each blank marked ___ , then run it.
    
    $password = "open-sesame";
    
    // 1) Turn the plain password into a storable hash.
    $hash = ___($password, PASSWORD_DEFAULT);   // 👉 the function that HASHES a password
    
    echo "Stored: " . substr($hash, 0, 7) . "...\n";
    
    // 2) A correct login must VERIFY the typed password against the stored hash.
    $typed = "open-sesame";
    $ok = ___($typed, $hash);                   // 👉 the function that CHECKS a password
    
    echo "Login correct? " . ($ok ? "yes" : "no") . "\n";
    
    // 3) A wrong password must fail.
    echo "Wrong pw? " . (password_verify("nope", $hash) ? "yes" : "no") . "\n";
    
    // ✅ Expected output:
    //    Stored: $2y$10$...
    //    Login correct? yes
    //    Wrong pw? no
    ?>
    Output
    Stored: $2y$10$...
    Login correct? yes
    Wrong pw? no
    Blank 1 is the function that hashes a password; blank 2 is the function that verifies one. Run it — the right password should report yes and the wrong one no.

    One more. This time finish the prepared statement so the email is bound safely instead of pasted into the SQL.

    🎯 Your turn: a safe prepared INSERT
    <?php
    // 🎯 YOUR TURN — finish the prepared statement so the email can't break the SQL.
    
    $pdo = new PDO("sqlite::memory:");
    $pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, password_hash TEXT)");
    
    $email = "grace@example.com";
    $hash  = password_hash("battery-staple", PASSWORD_DEFAULT);
    
    // 1) Use a ? placeholder for EACH value instead of putting them in the SQL.
    $stmt = $pdo->prepare(
        "INSERT INTO users (email, password_hash) VALUES (___, ___)"   // 👉 two placeholders
    );
    
    // 2) Pass the real values as an array, in the same order.
    $stmt->execute([___, ___]);                 // 👉 the email, then the hash
    
    $saved = $pdo->query("SELECT email FROM users")->fetchColumn();
    echo "Saved: $saved\n";
    
    // ✅ Expected output:
    //    Saved: grace@example.com
    ?>
    Output
    Saved: grace@example.com
    Put a ? placeholder for each value in the SQL, then pass $email and $hash (in that order) to execute(). The saved email should print.

    6️⃣ "Remember Me" & Rehashing on Login

    A normal session ends when the browser closes. A "remember me" feature keeps users signed in for weeks using a long-lived cookie — but never put the password or a plain user id in it. Instead generate a random token, store a hash of it in the database (treat it like a password), and send the token in an httponly, secure, samesite cookie. Separately, every successful login is a good moment to rehash: password_needs_rehash() tells you if your cost or algorithm has since been strengthened, so you can transparently upgrade the stored hash while you still have the plain password in hand.

    Remember-me cookie + password_needs_rehash
    <?php
    // "REMEMBER ME": store a random token (hashed in the DB) in a long-lived cookie.
    
    // On login, IF the user ticked "remember me":
    $selector = bin2hex(random_bytes(6));         // public lookup id
    $validator = bin2hex(random_bytes(32));       // secret, sent to the browser only
    $storeHash = hash('sha256', $validator);      // hash before saving — like a password
    
    // Save [selector, storeHash, user_id, expiry] in a remember_tokens table, then:
    setcookie("remember", "$selector:$validator", [
        "expires"  => time() + 60 * 60 * 24 * 30, // 30 days
        "path"     => "/",
        "httponly" => true,                       // JavaScript can't read it (anti-XSS)
        "secure"   => true,                       // HTTPS only
        "samesite" => "Lax",                      // limits cross-site sending (anti-CSRF)
    ]);
    
    // REHASH ON LOGIN: if your cost/algorithm has been upgraded, refresh the stored hash.
    $storedHash = '$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
    $plain = "s3cret-pw";
    if (password_verify($plain, $storedHash)
        && password_needs_rehash($storedHash, PASSWORD_DEFAULT, ["cost" => 12])) {
        $newHash = password_hash($plain, PASSWORD_DEFAULT, ["cost" => 12]);
        echo "Hash upgraded to cost 12 — save \$newHash back to the users row.\n";
    } else {
        echo "Hash is current — nothing to do.\n";
    }
    ?>
    Output
    Hash upgraded to cost 12 — save $newHash back to the users row.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Common Errors (and the fix)

    • Storing passwords as plain text (or md5/sha1) — the worst and most common mistake. If your database leaks, every account is instantly compromised. md5/sha1 are fast and unsalted, so they're cracked in seconds. Fix: always password_hash() on the way in and password_verify() on the way out — nothing else.
    • "Login failed" for a correct password because the column is too short — bcrypt hashes are 60 characters and may grow; an Argon2 hash is longer still. If your password_hash column is VARCHAR(20) the hash is silently truncated and verify never matches. Fix: use VARCHAR(255) (or TEXT).
    • Comparing secrets with == (timing attack)if ($a == $hash) leaks information through how long the comparison takes and can be fooled by PHP's loose typing. Fix: never hand-compare hashes — use password_verify() for passwords and hash_equals() for other tokens; both run in constant time.
    • No rehash check after upgrading cost/algorithm — you raise the bcrypt cost or move to Argon2, but old users keep their weak hashes forever. Fix: on each successful login, run password_needs_rehash() and re-store with password_hash() if it returns true.
    • Session fixation — not regenerating the id on login — if you keep the same session id from before login, an attacker who planted that id rides into the account. Fix: call session_regenerate_id(true) immediately after a successful login (and on logout).
    • "headers already sent" when starting a session or setting a cookiesession_start(), setcookie(), and header() must run before any output. A stray space or blank line before <?php counts as output. Fix: call them at the very top, before echoing anything.

    Pro Tips

    • 💡 Let password_hash manage the salt. Don't generate or store salts yourself — they're already inside the returned hash, which is why password_verify needs nothing extra.
    • 💡 One generic error message. Reply "invalid email or password" for every failure so attackers can't probe which emails are registered.
    • 💡 Add rate limiting. Hashing is slow on purpose; still, lock or slow down repeated failed logins to blunt brute-force attempts.
    • 💡 Always set login cookies httponly + secure + samesite. That blocks JavaScript theft (XSS) and limits cross-site sending (CSRF).

    📋 Quick Reference — PHP Authentication

    FunctionExampleWhat It Does
    password_hashpassword_hash($pw, PASSWORD_DEFAULT)Hash a password (salted, one-way)
    password_verifypassword_verify($pw, $hash)Check a password against a hash
    password_needs_rehashpassword_needs_rehash($hash, PASSWORD_DEFAULT)Should the stored hash be upgraded?
    $pdo->prepare$pdo->prepare("... WHERE email = ?")Build a safe parameterised query
    $stmt->execute$stmt->execute([$email])Bind values & run (no injection)
    session_startsession_start();Begin / resume the session
    session_regenerate_idsession_regenerate_id(true)New id on login (anti-fixation)
    $_SESSION$_SESSION['user_id'] = $id;Store who is logged in
    session_destroysession_destroy();Log the user out

    Frequently Asked Questions

    Q: Why can't I just store passwords with md5() or sha1()?

    md5 and sha1 are fast, general-purpose hashes built for speed, which is exactly wrong for passwords — a modern GPU can try billions of md5 guesses per second, and they have no salt, so identical passwords produce identical hashes that attackers can crack from a precomputed rainbow table. password_hash() uses a slow, salted, password-specific algorithm (bcrypt or Argon2) that is deliberately expensive to brute-force. Always use password_hash() and password_verify(); never md5, sha1, or your own scheme.

    Q: How does password_verify() work if I can't 'decrypt' the hash?

    Hashing is one-way: you can't turn a hash back into the password. Instead, password_verify() reads the algorithm, cost, and salt that password_hash() stored inside the hash string itself, re-hashes the password the user just typed using those same parameters, and compares the result to the stored hash. If they match, the password was correct. That's why you never need (and never want) to store the original password.

    Q: What is a timing attack, and does password_verify() protect me?

    A timing attack measures tiny differences in how long a comparison takes to leak whether the first few characters matched. password_verify() and hash_equals() compare in constant time, so they don't leak that information — use them rather than the == operator for secrets. Also return the SAME generic 'invalid email or password' message and similar timing whether the email is unknown or the password is wrong, so attackers can't tell which accounts exist.

    Q: What is session fixation and why call session_regenerate_id()?

    In a session fixation attack, an attacker tricks a victim into using a session id the attacker already knows; once the victim logs in, the attacker rides that same id into the authenticated account. Calling session_regenerate_id(true) right after a successful login issues a brand-new session id and deletes the old one, so any id an attacker planted becomes useless. Regenerate the id on every privilege change (login, logout, role change).

    Q: When should I use 'remember me' cookies versus just sessions?

    A normal PHP session lasts until the browser closes or the session expires. A 'remember me' feature keeps users logged in for days or weeks using a long-lived cookie that stores a random token — never the password and never a plain user id. Store a hash of the token in the database (treat it like a password), set the cookie httponly, secure, and samesite, and rotate the token on each use so a stolen cookie has a short useful life.

    Q: Do I need a framework or a JWT library for login?

    No — plain PHP gives you everything for classic server-rendered apps: password_hash/password_verify for credentials, PDO prepared statements for storage, and $_SESSION for login state. Frameworks like Laravel and Symfony bundle these patterns (plus CSRF protection and rate limiting) so you write less boilerplate, and tokens like JWT are useful for stateless APIs — but the core security ideas in this lesson are the same underneath.

    Mini-Challenge: A Tiny Auth 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 the exact register-then-verify loop behind every real login screen.

    🎯 Mini-Challenge: register and log in a user
    <?php
    // 🎯 MINI-CHALLENGE: a tiny auth flow. No code is filled in — work from the steps.
    //
    // 1. Create an in-memory PDO sqlite DB with a users table
    //    (columns: id, email UNIQUE, password_hash).
    // 2. Write register($pdo, $email, $password):
    //      - hash the password with password_hash()
    //      - INSERT it with a PREPARED statement (use ? placeholders).
    // 3. Write login($pdo, $email, $password) that returns true/false:
    //      - SELECT the password_hash for that email (prepared statement)
    //      - return false if there's no user
    //      - otherwise return password_verify($password, $hash).
    // 4. Register "sam@example.com" / "letmein", then test:
    //      - login with the WRONG password  -> false
    //      - login with the RIGHT password  -> true
    //
    // Tip: never store $password itself — only the hash from password_hash().
    //
    // ✅ Expected output:
    //    Wrong password: false
    //    Right password: true
    
    // your code here
    ?>
    Build a users table, write register() (hash + prepared INSERT) and login() (lookup + password_verify), then prove the wrong password is rejected and the right one accepted.

    🎉 Lesson Complete!

    • ✅ Passwords are hashed with password_hash() (bcrypt/Argon2) and never stored as plain text, md5, or sha1
    • ✅ Logins are checked with password_verify() — you compare hashes, you never decrypt
    • ✅ Users are saved and looked up with PDO prepared statements, which stop SQL injection
    • ✅ Login state lives in $_SESSION; you session_regenerate_id() on login and session_destroy() to log out
    • ✅ "Remember me" stores a hashed random token in a hardened cookie, and password_needs_rehash() keeps hashes current
    • ✅ Protect pages by checking the session at the top and exit-ing guests out
    • Next lesson: Advanced Security — CSRF tokens, XSS escaping, and hardening your app end to end

    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

    Install LearnCodingFast

    Learn faster with the app on your home screen.