PHP scripts commonly handle user input, a database, and a login system — which means they're also commonly the target of a handful of well-understood attacks. Each one has a specific, practical defense.
Building a query by directly inserting user input into the SQL string lets an attacker inject their own SQL — the earlier MySQL lessons' use of PDO prepared statements is exactly what prevents this, and it's worth understanding why.
// VULNERABLE — never do this
$username = $_GET['username'];
$sql = "SELECT * FROM users WHERE username = '$username'";
// An attacker submitting: ' OR '1'='1
// turns this into a query that matches every row// SAFE — a prepared statement
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');
$stmt->execute([$_GET['username']]);
// The input is sent separately from the SQL — it can never be
// interpreted as part of the query itself, no matter what it containsThe one rule that matters most here
String-concatenating any user input directly into a SQL query is the single most common serious PHP security bug. A prepared statement isn't an optional improvement — treat it as mandatory for every query that includes user input.
If user input is echoed back into a page without escaping it, an attacker can submit HTML or JavaScript that runs in another visitor's browser — stealing their session, or acting on their behalf.
// VULNERABLE
echo "Welcome, " . $_GET['name'];
// A name of <script>stealCookies()</script> runs as real JavaScript
// SAFE — escape before output
echo "Welcome, " . htmlspecialchars($_GET['name']);
// The script tag is displayed as harmless text, not executedEscape everything, not just the suspicious parts
The rule of thumb: escape on output, every time, with htmlspecialchars() — not just for input that "looks risky." It costs nothing when the input was harmless anyway.
Without protection, a malicious site can trick a logged-in user's browser into submitting a form to your site — the browser automatically includes the user's session cookie, making the request look legitimate. A CSRF token defeats this: a random value stored in the session and required on every form submission.
// When rendering the form
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
echo '<input type="hidden" name="csrf_token" value="' . $_SESSION['csrf_token'] . '">';
// When processing the form submission
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die('Invalid request.');
}A password must never be stored as plain text or with a simple hash like MD5 — PHP's built-in password_hash() uses a strong, salted, deliberately slow algorithm designed specifically for this.
// When a user registers
$hashed = password_hash($_POST['password'], PASSWORD_DEFAULT);
// Store $hashed in the database — never the plain password
// When a user logs in
if (password_verify($_POST['password'], $storedHash)) {
// Correct password
}| Issue | Defense |
|---|---|
| SQL Injection | Prepared statements — never concatenate input into SQL |
| XSS | htmlspecialchars() on every piece of output that includes user input |
| CSRF | A random token in the session, checked on every state-changing form submission |
| Plain-text passwords | password_hash() to store, password_verify() to check |
Related, from the other side
This lesson covers the specific PHP-level defenses. The Cybersecurity course elsewhere on this site covers phishing, password managers, and account security from the user's side — worth pairing with this one.