Take apart XSS, CSRF and SQL injection to understand exactly what htmlspecialchars, tokens and prepared statements actually block.
Open this lesson in KodokonThe three great families of web vulnerabilities - XSS, CSRF and injections - all share the same root cause: data supplied by the user ends up interpreted as code (HTML, SQL or a legitimate request). Let's start with XSS: if you display a value from $_GET without escaping, an attacker can inject a <script> tag and run JavaScript in your other visitors' browsers - stealing sessions, performing actions without their knowledge. With ?comment=<script>alert(1)</script>, the first output below runs the script; the second neutralizes it. Two flags matter here: ENT_QUOTES also converts single quotes (otherwise an attribute delimited by single quotes stays injectable), and ENT_SUBSTITUTE replaces invalid UTF-8 sequences instead of returning an empty string. Since PHP 8.1, both flags are finally the default, but spelling them out documents your intent and protects code that runs on older versions.
<?php
declare(strict_types=1);
$comment = $_GET['comment'] ?? '';
echo '<div>' . $comment . '</div>';
function e(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
echo '<div>' . e($comment) . '</div>';CSRF exploits one simple fact: the browser automatically attaches session cookies to any request toward your domain, even one triggered from a third-party site. A hostile form can therefore submit /account/delete using the victim's session. The classic defense is the synchronizer token: a random secret stored in the session and required in every mutating request. A third-party site can neither read it nor guess it, so it cannot supply it.
<?php
session_start();
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
&& hash_equals($_SESSION['csrf'], $sent);
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
http_response_code(403);
exit('Invalid CSRF token');
}
?>
<form method="post">
<input type="hidden" name="csrf"
value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<button>Delete my account</button>
</form>SQL injection follows the same pattern: concatenating a user value into a query amounts to letting the user write SQL - ' OR 1=1 -- turns a filter into the whole table. The solution is not escaping but the prepared statement: the query text (with its :email placeholders) and the values travel separately to the engine, which therefore can never confuse data with code. An expert detail: with MySQL, PDO emulates prepared statements by default, escaping on the client side; set PDO::ATTR_EMULATE_PREPARES to false for genuinely native prepared statements.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
'CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL
)'
);
$email = $_GET['email'] ?? '';
$stmt = $pdo->prepare(
'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);
var_dump($stmt->fetch(PDO::FETCH_ASSOC));