SQL injection happens when user input becomes part of the SQL command. The attacker’s text is treated as SQL, not as data. It is one of the oldest and most damaging web bugs, and it is entirely preventable.
A vulnerable login check
<?php
$pdo = require __DIR__ . "/db.php";
$email = "' OR '1'='1"; // what an attacker types into the email field
// DANGEROUS: the input is joined into the SQL text
$sql = "SELECT name FROM users WHERE email = '$email'";
echo $sql . "\n\n";
$names = $pdo->query($sql)->fetchAll(PDO::FETCH_COLUMN);
echo "Rows returned: " . count($names);
SELECT name FROM users WHERE email = '' OR '1'='1'
Rows returned: 3
Read the SQL that PHP built. The attacker’s text closed the quote and added OR '1'='1', which is always true. The query returns every user. In a login form, that can log the attacker in as the first user in the table.
What attackers can do
- Bypass logins.
- Read data from any table (users, emails, password hashes, orders).
- Change or delete data.
- Sometimes run commands on the server, depending on the database setup.
Real data breaches have started with exactly this kind of mistake.
The fix: prepared statements
<?php
$pdo = require __DIR__ . "/db.php";
$email = "' OR '1'='1";
$statement = $pdo->prepare("SELECT name FROM users WHERE email = :email");
$statement->execute(["email" => $email]);
echo "Rows returned: " . count($statement->fetchAll());
Rows returned: 0
The text is sent as data. The database looks for a user whose email is literally ' OR '1'='1, and finds none. See Prepared Statements.
Where injection hides
| Place | Safe version |
|---|---|
A value in WHERE, INSERT, UPDATE | Placeholder: :email |
LIKE search | Placeholder, with % added to the value |
LIMIT and OFFSET | Bind with PDO::PARAM_INT |
IN (...) lists | One ? per value, generated by you |
ORDER BY column and direction | Allow-list (placeholders cannot be used) |
| Table or column names | Allow-list |
<?php
$allowed = ["name", "price"];
$sort = "price; DROP TABLE products";
$column = in_array($sort, $allowed, true) ? $sort : "name";
echo "ORDER BY " . $column;
ORDER BY name
What does not protect you
addslashes()and manual escaping: easy to get wrong with character sets.- Checking for bad words such as
DROPor--: attackers have many ways around it. - “Validated by JavaScript”: the attacker does not use your form.
- Numbers:
$id = $_GET["id"]is a string. Cast or validate it, and still use a placeholder. - Hiding error messages: that only hides the symptom. The injection still works (“blind” injection).
Defence in depth
- Prepared statements everywhere (the main defence).
- Validate input (type, length, allowed values).
- A least-privilege database user: only
SELECT,INSERT,UPDATE,DELETEon your own database. - No detailed database errors on screen (see Production Error Handling).
- Keep software updated.
Exercise: spot the injection
Which of these is vulnerable? (a) $pdo->query("SELECT * FROM posts WHERE id = " . (int) $_GET["id"]), (b) $pdo->query("SELECT * FROM posts WHERE slug = '" . $_GET["slug"] . "'"), (c) $pdo->prepare("SELECT * FROM posts ORDER BY " . $_GET["sort"]).
Show the answer
(b) is vulnerable: the slug is joined into the SQL as text. (c) is vulnerable too: prepare() does not help when the user input is part of the SQL string itself, and ORDER BY must use an allow-list. (a) is safe in practice, because (int) turns any input into a number, though a placeholder is still the better habit.
Why this matters in WordPress and Laravel
$wpdb->prepare() for the same job, and Laravel’s query builder and Eloquent bind values for you. A plugin that builds SQL by joining strings is a common source of WordPress vulnerabilities.