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
$pdo->prepare() looks unfamiliar, do the PHP Databases (PDO) lesson first, then come back here.php file.php. The Output panel under each example shows what to expect. (Hashes use a random salt, so your exact hash string will differ each run — that's correct.)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);
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";
?>Stored hash:
$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
Hashed again (different salt):
$2y$10$IjZAgcfl7p92ldGxad68LeoN9qo8uLOickgx2ZMRZoMyeMkR3pFq.
Right password? yes
Wrong password? noNotice 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";
?>bcrypt (cost 12): $2y$12$...
argon2id: $argon2id...
Algorithm name: bcryptUse 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, "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";
?>Registered ada@example.com (id 1)
Saved email: ada@example.com
Saved value is a hash, not the password: trueThe 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 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";
?>login failed
logged in
Session user_id: 15️⃣ 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();
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
}
?>Welcome back, user #1 — this is your private dashboard.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
?>Stored: $2y$10$...
Login correct? yes
Wrong pw? noyes 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.
<?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
?>Saved: grace@example.com? 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.
<?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";
}
?>Hash upgraded to cost 12 — save $newHash back to the users row.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 andpassword_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_hashcolumn isVARCHAR(20)the hash is silently truncated and verify never matches. Fix: useVARCHAR(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 — usepassword_verify()for passwords andhash_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 withpassword_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 cookie —
session_start(),setcookie(), andheader()must run before any output. A stray space or blank line before<?phpcounts as output. Fix: call them at the very top, before echoing anything.
Pro Tips
- 💡 Let
password_hashmanage the salt. Don't generate or store salts yourself — they're already inside the returned hash, which is whypassword_verifyneeds 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
| Function | Example | What It Does |
|---|---|---|
| password_hash | password_hash($pw, PASSWORD_DEFAULT) | Hash a password (salted, one-way) |
| password_verify | password_verify($pw, $hash) | Check a password against a hash |
| password_needs_rehash | password_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_start | session_start(); | Begin / resume the session |
| session_regenerate_id | session_regenerate_id(true) | New id on login (anti-fixation) |
| $_SESSION | $_SESSION['user_id'] = $id; | Store who is logged in |
| session_destroy | session_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.
<?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
?>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; yousession_regenerate_id()on login andsession_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.