Authentication Deep Dive

Reviewed & published by Brayan K

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.

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️⃣ 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.

<?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);
// A real one looks like this, and yours will NOT match it:
//   $2y$12$e31XYn0yVzVtNothCrJTruXT.M29uGWP/AO9I9iYhYfPKHY5wleqi
// so print the parts that are the same every time instead.
echo "Algorithm: " . password_get_info($hash)["algoName"] . "\n";
echo "Length:    " . strlen($hash) . " characters\n\n";

// The same password hashed TWICE gives two DIFFERENT hashes — each has a random salt.
$again = password_hash($plain, PASSWORD_DEFAULT);
echo "Same password, same hash? " . ($hash === $again ? "yes" : "no") . "\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";
?>

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.

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

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.

<?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, "[email protected]", "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";
?>

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

<?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 [email protected] / "s3cret-pw" from registration.)
echo login($pdo, "[email protected]", "wrong-pw") ? "logged in\n" : "login failed\n";
echo login($pdo, "[email protected]", "s3cret-pw") ? "logged in\n" : "login failed\n";
echo "Session user_id: " . ($_SESSION['user_id'] ?? "none") . "\n";
?>

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.

<?php
// PROTECT A PAGE: anything below this guard only runs for logged-in users.
session_start();

// In a real app the login step put this there; set it here so the demo runs
// the logged-in path. Comment it out and the guard redirects instead.
$_SESSION['user_id'] = 1;

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

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.

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

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

<?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 = "[email protected]";
$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: [email protected]
?>

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.

<?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.
$plain = "s3cret-pw";
$storedHash = password_hash($plain, PASSWORD_BCRYPT, ["cost" => 10]);  // an old row
// Name the cost on both sides: PASSWORD_DEFAULT's cost has changed between
// PHP versions, so comparing against it would give a different answer per server.
if (password_verify($plain, $storedHash)
    && password_needs_rehash($storedHash, PASSWORD_BCRYPT, ["cost" => 12])) {
    $newHash = password_hash($plain, PASSWORD_BCRYPT, ["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";
}
?>

Common Errors (and the fix)

Pro Tips

📋 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

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.

<?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 "[email protected]" / "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
?>

🎉 Lesson Complete!

Practice quiz

Which function should you use to hash a password for storage?

  • md5()
  • sha1()
  • password_hash()
  • base64_encode()

Answer: password_hash(). password_hash() uses a slow, salted, password-specific algorithm (bcrypt or Argon2). md5/sha1 are fast, unsalted, and unsafe for passwords.

How do you check a typed password against a stored hash?

  • password_verify($typed, $hash)
  • Decrypt the hash and compare
  • $typed === $hash
  • md5($typed) == $hash

Answer: password_verify($typed, $hash). Hashing is one-way; you never decrypt. password_verify() re-hashes the typed password with the stored salt and compares in constant time.

Why does hashing the same password twice give two different hashes?

  • The function is broken
  • PHP caches the first result
  • The cost factor changes randomly
  • password_hash() adds a random salt each time

Answer: password_hash() adds a random salt each time. Each call adds a random salt, so identical passwords produce different hashes — defeating rainbow tables. The salt is stored inside the hash.

What does PASSWORD_DEFAULT currently use as its algorithm?

  • md5
  • bcrypt
  • AES
  • ROT13

Answer: bcrypt. PASSWORD_DEFAULT currently means bcrypt (hashes start with $2y$). It can upgrade automatically in future PHP versions.

Why use PDO prepared statements when saving a user?

  • They keep user input out of the SQL, preventing SQL injection
  • They run faster than plain queries always
  • They hash the password automatically
  • They compress the database

Answer: They keep user input out of the SQL, preventing SQL injection. Prepared statements use ? or :name placeholders so values are bound as data, never as SQL — defeating injection like '; DROP TABLE users; --.

Where does PHP track that a user is logged in?

  • In a global $loggedIn variable
  • In the URL query string
  • In $_SESSION after session_start()
  • In a cookie holding the plaintext password

Answer: In $_SESSION after session_start(). After session_start() you read/write $_SESSION; here the user's id is stored to remember who is logged in.

Why call session_regenerate_id(true) right after a successful login?

  • To log the user out
  • To prevent session fixation by issuing a brand-new session id
  • To clear the database
  • To speed up the session

Answer: To prevent session fixation by issuing a brand-new session id. It issues a new session id and deletes the old one, so any id an attacker planted (session fixation) becomes useless.

What is a timing attack, and what protects you?

  • A DDoS on the login page — a firewall stops it
  • A slow database query — an index fixes it
  • An expired session — a longer TTL fixes it
  • Measuring comparison time to leak matches — password_verify/hash_equals compare in constant time

Answer: Measuring comparison time to leak matches — password_verify/hash_equals compare in constant time. Constant-time comparisons (password_verify, hash_equals) don't leak how many characters matched; never compare secrets with ==.

For a 'remember me' cookie, what should you store in the database?

  • The user's plaintext password
  • A hash of the random token (treated like a password)
  • The plain token exactly as sent to the browser
  • The user's email in plain text only

Answer: A hash of the random token (treated like a password). Store a hash of the random token (never the password, never a plain id), and set the cookie httponly, secure, and samesite.

What does password_needs_rehash() tell you?

  • Whether the password is correct
  • Whether the user exists
  • Whether the stored hash should be upgraded to a stronger cost/algorithm
  • Whether the session expired

Answer: Whether the stored hash should be upgraded to a stronger cost/algorithm. On a successful login it reports if your cost/algorithm was strengthened, so you can transparently re-store a stronger hash while you have the plain password.

Continue this course

Frequently asked questions

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.

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.

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.

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

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.

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.

Related lessons