SQL injection works by breaking out of a string value and injecting SQL syntax — raw string concatenation is the root cause
The only real fix: prepared statements, which separate SQL structure from data at the protocol level
PDO default has ATTR_EMULATE_PREPARES = true — this still concatenates in PHP and reintroduces injection risk; set it to false
MySQLi bind_param() type string: s=string, i=integer, d=decimal, b=blob — wrong type does not prevent injection but causes data corruption
Defence in depth: prepared statements + input validation + htmlspecialchars() on output + least-privilege DB user
Biggest mistake: using PDO::quote() and thinking it is as safe as prepared statements — it is not
Plain-English First
Imagine you run a library. A visitor fills out a slip asking for 'Harry Potter'. Simple — you find the book. Now imagine a sneaky visitor writes: 'Harry Potter. Also, delete every book in the library.' If your librarian follows those instructions literally, disaster strikes. SQL injection is exactly that: an attacker sneaks extra database commands into a normal input field, and a naive PHP script obeys them blindly. Prepared statements are like hiring a smarter librarian who separates the request form from the instructions — so no matter what someone writes on the slip, it's always treated as a book title, never a command.
Every year, SQL injection ranks in the OWASP Top 10 most critical web security risks — and it has for over two decades. It's not a niche academic threat. It's the vulnerability behind some of the most embarrassing data breaches in history: stolen user credentials, exposed credit card numbers, entire databases wiped overnight. The scary part? A single unguarded PHP script connecting to MySQL is all it takes. One $_GET variable dropped raw into a query, and an attacker owns your database.
The core problem is trust. Early PHP database code treated user input as part of the SQL command itself. When you concatenate a username from a form directly into a query string, you're handing the attacker a microphone on your database server. They can close your query early, append their own commands, comment out the rest of your SQL, and walk away with whatever they want. The fix isn't complicated — but it requires understanding why the old approach was broken, not just memorising a new syntax.
By the end of this article you'll understand exactly how SQL injection works under the hood, why prepared statements eliminate the vulnerability at the protocol level (not just cosmetically), and how to write production-grade PHP database code using both PDO and MySQLi. You'll also know the three mistakes that catch even experienced developers off guard — and how interviewers probe for this knowledge.
How SQL Injection Actually Works — And Why Raw Queries Are Dangerous
To prevent something you have to understand it first. Let's look at what actually happens when PHP builds a query by gluing strings together.
Suppose you have a login form. A user types their email and password, and your PHP script checks the database. The vulnerable version looks harmless until you realise that $_POST['email'] is completely user-controlled. An attacker doesn't type their email — they type a carefully crafted string that changes the meaning of your SQL.
The input ' OR '1'='1 turns a normal login check into a query that returns every user in the table. The attacker is now logged in as the first user — likely an admin. Even worse, the input '; DROP TABLE users; -- could wipe your entire users table if your database user has that permission. The double-dash -- is a SQL comment that silences the rest of your original query so there's no syntax error to trip the attacker up.
This works because MySQL receives one big string and has no idea where your intended SQL ends and the attacker's injected SQL begins. It's all just text to the parser. That's the fundamental flaw — and the fix is separating the query structure from the data, permanently, at the driver level.
vulnerable_login.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
// ============================================================// WARNING: THIS IS THE VULNERABLE PATTERN — DO NOT USE IN PRODUCTION// It exists here so you can see exactly what you must avoid.// ============================================================
$host = 'localhost';
$database = 'forge_app';
$username = 'forge_user';
$password = 'secret';
// Old-school mysqli connection (no prepared statements yet)
$connection = mysqli_connect($host, $username, $password, $database);
if (!$connection) {
die('Database connection failed: ' . mysqli_connect_error());
}
// Imagine these come from a login form POST — user-controlled input
$emailFromForm = $_POST['email'] ?? '';
$passwordFromForm = $_POST['password'] ?? '';
// THE VULNERABILITY: raw user input is concatenated directly into SQL// An attacker can set $emailFromForm = "' OR '1'='1" to bypass the password check
$unsafeQuery = "SELECT id, email, role
FROM users
WHERE email = '$emailFromForm'AND password = '$passwordFromForm'";
// With the injected input, this actually executes:// SELECT id, email, role FROM users// WHERE email = '' OR '1'='1'// AND password = 'anything'// '1'='1' is always TRUE, so every row matches — instant bypass.
$result = mysqli_query($connection, $unsafeQuery);
if ($result && mysqli_num_rows($result) > 0) {
$user = mysqli_fetch_assoc($result);
// Attacker is now authenticated as this user — often the first adminecho"Logged in as: " . $user['email'] . " (role: " . $user['role'] . ")";
} else {
echo"Invalid credentials.";
}
mysqli_close($connection);
?>
Output
-- Attack: username = ' OR '1'='1
-- Resulting query: SELECT * FROM users WHERE username='' OR '1'='1' AND password='...'
-- '1'='1' is always true -- attacker logs in as first user in the table
Watch Out:
Even mysqli_real_escape_string() is not a complete fix. It escapes quotes but fails against certain character encodings and doesn't protect against all injection vectors. Prepared statements are the only reliable defence — they make injection structurally impossible, not just harder.
Production Insight
SQL injection is not a beginner mistake — it is an architectural mistake. Any code that builds query strings with user data is vulnerable regardless of escape functions.
The OWASP Top 10 has included injection in the top 3 for over 15 years — it is still the most commonly exploited web vulnerability.
A single injectable query in an otherwise hardened application is sufficient for full database exfiltration.
Key Takeaway
Injection works by breaking SQL string delimiters — the root cause is concatenating untrusted data into SQL structure.
Escaping functions are workarounds that fail in edge cases — they are not equivalent to prepared statements.
One vulnerable query endpoint is sufficient for full database exfiltration.
The Right Fix: PDO Prepared Statements That Make Injection Structurally Impossible
Prepared statements fix SQL injection at the protocol level. Here's what that means in practice: when you use a prepared statement, your PHP code sends the SQL template to MySQL first — with placeholders where data will go. MySQL parses and compiles that template into an execution plan. Then, separately, your data values are sent. By the time the data arrives, the query structure is already locked in. MySQL treats everything in those data slots as a literal value, never as SQL syntax — no matter what characters it contains.
PDO (PHP Data Objects) is the recommended approach because it's database-agnostic. You can switch from MySQL to PostgreSQL or SQLite by changing one line. MySQLi works too, but it's MySQL-only. Both use the same prepared statement concept under the hood.
There are two placeholder styles in PDO: named placeholders (:email) and positional placeholders (?). Named placeholders are more readable, especially when you have many parameters. Positional are slightly more concise. Pick one style and stay consistent within a project.
Notice in the code below that execute() receives an array of actual values. PDO sends those values over a separate channel from the SQL text. Even if the value contains '; DROP TABLE users; --, MySQL sees it as a string value for the email column — not SQL commands. The injection attempt is completely inert.
secure_login_pdo.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<?php
// ============================================================// SECURE VERSION — PDO with prepared statements// This is production-grade code. Use this pattern.// ============================================================
$host = 'localhost';
$dbName = 'forge_app';
$dbUser = 'forge_user';
$dbPass = 'secret';
// PDO DSN (Data Source Name) — tells PDO which driver and database to use
$dsn = "mysql:host=$host;dbname=$dbName;charset=utf8mb4";
// Connection options that every production app should set
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // Throw exceptions on errorsPDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // Return rows as associative arraysPDO::ATTR_EMULATE_PREPARES => false, // Use REAL prepared statements, not emulated ones
];
try {
$pdo = newPDO($dsn, $dbUser, $dbPass, $options);
} catch (PDOException $connectionError) {
// Log the real error internally — never expose DB details to the usererror_log('DB connection failed: ' . $connectionError->getMessage());
die('Service temporarily unavailable. Please try again later.');
}
// Collect user input — in real apps, you'd also validate format here
$emailInput = $_POST['email'] ?? '';
$passwordInput = $_POST['password'] ?? '';
// --- STEP 1: Define the SQL template with named placeholders ---// MySQL receives and compiles THIS string. No user data is in it yet.
$sqlTemplate = "SELECT id, email, password_hash, role
FROM users
WHERE email = :email
LIMIT1";
// --- STEP 2: Prepare the statement ---
$statement = $pdo->prepare($sqlTemplate);
// --- STEP 3: Execute with the actual data values ---// These values are sent separately — MySQL treats them as data, NEVER as SQL.// Even if $emailInput = "' OR '1'='1", it's just a string being compared to the email column.
$statement->execute([':email' => $emailInput]);
// --- STEP 4: Fetch the result ---
$matchedUser = $statement->fetch();
if ($matchedUser && password_verify($passwordInput, $matchedUser['password_hash'])) {
// password_verify() safely compares the plain input against the stored bcrypt hashecho"Welcome back, " . htmlspecialchars($matchedUser['email']) . "!";
echo" Your role is: " . htmlspecialchars($matchedUser['role']);
// In a real app: session_start(); $_SESSION['user_id'] = $matchedUser['id'];
} else {
// Deliberately vague — don't tell attackers whether the email or password was wrongecho"Invalid email or password.";
}
?>
Output
// Attack attempt: username = ' OR '1'='1
// PDO sends the SQL template and the value separately
// MySQL receives: WHERE email = ? with bound value [' OR '1'='1']
// The value is treated as a literal string -- injection is impossible
By default, PDO emulates prepared statements in PHP — it still concatenates values into SQL before sending to MySQL. That reintroduces injection risk in edge cases. Setting this to false forces PDO to use MySQL's native prepared statement protocol, where query and data travel separately at the network level. Always set it.
Production Insight
PDO with EMULATE_PREPARES enabled (the default) is not safe — it still concatenates in PHP. Always explicitly set it to false in your DSN or setAttribute call.
For legacy applications being migrated, search for all ->query() and ->exec() calls with string interpolation — these are the attack surface.
Prepared statements are also faster for repeated queries — the server parses the template once and reuses the execution plan.
Key Takeaway
Prepared statements separate SQL structure from data at the protocol level — injection is architecturally impossible.
PDO::ATTR_EMULATE_PREPARES = false is mandatory — the default true setting reintroduces the vulnerability.
Prepared statements also improve performance for repeated queries — parse once, execute many.
MySQLi Prepared Statements and Binding — When You're Working With MySQLi
PDO is the modern default, but you'll encounter MySQLi — especially in WordPress plugins, legacy codebases, and older hosting environments. MySQLi's prepared statement API is slightly more verbose, using bind_param() to explicitly declare the data types of each parameter.
The type string in bind_param() is critical and often misunderstood. Each character represents a parameter: s for string, i for integer, d for double (float), b for blob. If you bind an integer as a string, MySQL coerces it — usually harmless, but it's sloppy. More importantly, getting this right proves you understand what's actually happening at the driver level.
MySQLi also supports get_result() which returns a familiar result object, but it requires the mysqlnd driver (which is standard on PHP 7+). The bind_result() alternative works without mysqlnd but is clunkier for multi-column results.
The security guarantee is identical to PDO: the SQL template and the data are sent to MySQL separately. Injection is structurally impossible regardless of what the user submits.
secure_user_search_mysqli.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
// ============================================================// SECURE MySQLi — prepared statements with bind_param()// Common in WordPress ecosystems and legacy PHP applications// ============================================================
$host = 'localhost';
$dbUser = 'forge_user';
$dbPass = 'secret';
$dbName = 'forge_app';
// Object-oriented MySQLi style (preferred over procedural)
$mysqli = newmysqli($host, $dbUser, $dbPass, $dbName);
if ($mysqli->connect_errno) {
error_log('MySQLi connect error: ' . $mysqli->connect_error);
die('Service temporarily unavailable.');
}
// Set charset explicitly — always do this to avoid encoding-based injection vectors
$mysqli->set_charset('utf8mb4');
// Imagine this is a search feature — user searches for members by username
$searchTerm = $_GET['username'] ?? '';
$minAge = (int) ($_GET['min_age'] ?? 18); // Cast to int immediately — never trust input// SQL template — two placeholders: one string, one integer
$sqlTemplate = "SELECT id, username, email, age
FROM members
WHERE username LIKE ?
AND age >= ?
ORDERBY username ASCLIMIT50";
// Prepare the statement
$statement = $mysqli->prepare($sqlTemplate);
if (!$statement) {
error_log('Prepare failed: ' . $mysqli->error);
die('Search unavailable.');
}
// Wrap the search term in % wildcards for a LIKE search// This is safe — the wildcards are added BEFORE binding, so they're part of the data value
$likePattern = '%' . $searchTerm . '%';
// bind_param(types, ...variables)// 'si' = first param is string (s), second is integer (i)// Variables are bound by REFERENCE — they're evaluated at execute() time
$statement->bind_param('si', $likePattern, $minAge);
// Execute — data travels to MySQL separately from the SQL template
$statement->execute();
// get_result() requires mysqlnd driver (standard on PHP 7+)
$results = $statement->get_result();
if ($results->num_rows === 0) {
echo"No members found matching '" . htmlspecialchars($searchTerm) . "'.";
} else {
echo"Found " . $results->num_rows . " member(s):\n\n";
while ($member = $results->fetch_assoc()) {
// htmlspecialchars() prevents XSS when displaying data back in HTMLecho"- " . htmlspecialchars($member['username'])
. " (" . htmlspecialchars($member['email']) . ")"
. " Age: " . (int)$member['age'] . "\n";
}
}
// Always close the statement and connection
$statement->close();
$mysqli->close();
?>
Output
// bind_param('si', $name, $id) binds:
// s = string (name parameter)
// i = integer (id parameter)
// Wrong type string causes data corruption but does not prevent injection
// Always match types to your schema
Pro Tip:
When using LIKE with prepared statements, add the % wildcards to your PHP variable before binding — not inside the SQL template. Writing LIKE '%?%' is invalid syntax. The correct pattern is $pattern = '%' . $userInput . '%' then bind $pattern. The wildcards become part of the data value, which is exactly right.
Production Insight
MySQLi is prevalent in WordPress, legacy enterprise PHP, and environments using older hosting stacks — knowing it is a practical necessity.
The bind_param() type string (ssid) must match your actual column types — wrong types cause silent data corruption even if injection is prevented.
For new code, prefer PDO — it is database-agnostic and has a more consistent API.
Key Takeaway
MySQLi bind_param() type strings must match column types exactly — s/i/d/b mismatch causes silent data corruption.
Prefer PDO for new code — database-agnostic, consistent API.
MySQLi is necessary knowledge for WordPress and legacy PHP ecosystems.
Beyond Queries: Input Validation, Output Encoding, and Least Privilege
Prepared statements are your primary defence — but a real production application uses defence in depth. Think of it as layers: if one layer fails, the next one catches it.
Input Validation means rejecting data that doesn't fit your expected format before it touches the database. If a field expects a UK postcode, validate it with a regex. If it expects an integer ID, cast it with (int) immediately after reading from $_GET or $_POST. This isn't a substitute for prepared statements — it's an additional filter that catches garbage data early and makes your code's intent explicit.
Output Encoding is separate but critical. htmlspecialchars() converts <, >, &, and quotes into HTML entities. This prevents stored XSS — where an attacker injects JavaScript into your database that then executes in other users' browsers. Always encode output when rendering to HTML. Never echo database values raw.
Database Least Privilege is the most overlooked layer. Your PHP application's database user should only have the permissions it actually needs. A read-heavy app needs SELECT only. A typical CRUD app needs SELECT, INSERT, UPDATE, DELETE — but never DROP, CREATE, or GRANT. If an attacker somehow bypasses everything else, a restricted DB user stops them from deleting tables or creating backdoor accounts.
hardened_profile_update.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<?php
// ============================================================// HARDENED UPDATE EXAMPLE — Input validation + prepared statement// + output encoding + principle of least privilege in action// ============================================================// --- Input Validation Layer ---// Validate BEFORE touching the database. Fail fast with clear errors.
$userId = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT);
$newUsername = trim(filter_input(INPUT_POST, 'username', FILTER_SANITIZE_SPECIAL_CHARS) ?? '');
$newEmail = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$newAge = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 13, 'max_range' => 120]
]);
$validationErrors = [];
if ($userId === false || $userId === null) {
$validationErrors[] = 'Invalid user ID.';
}
if (strlen($newUsername) < 3 || strlen($newUsername) > 30) {
$validationErrors[] = 'Username must be 3-30 characters.';
}
if ($newEmail === false) {
$validationErrors[] = 'Invalid email address format.';
}
if ($newAge === false) {
$validationErrors[] = 'Age must be between 13 and 120.';
}
if (!empty($validationErrors)) {
// Return errors before any database work happenshttp_response_code(422);
foreach ($validationErrors as $error) {
// htmlspecialchars() here because we're outputting to HTMLechohtmlspecialchars($error) . "\n";
}
exit;
}
// --- Database Layer — PDO prepared statement ---
$dsn = 'mysql:host=localhost;dbname=forge_app;charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = newPDO($dsn, 'forge_app_writer', 'writerpass', $options);
// Note: forge_app_writer only has SELECT, INSERT, UPDATE, DELETE// It CANNOT DROP tables, CREATE users, or GRANT permissions
$updateStatement = $pdo->prepare(
"UPDATE users
SET username = :username,
email = :email,
age = :age,
updated_at = NOW()
WHERE id = :userId"
);
$updateStatement->execute([
':username' => $newUsername,
':email' => $newEmail,
':age' => $newAge,
':userId' => $userId,
]);
$rowsAffected = $updateStatement->rowCount();
if ($rowsAffected === 1) {
// Output encoding before rendering to HTML — prevents stored XSSecho"Profile updated for: " . htmlspecialchars($newUsername);
} else {
echo"No profile found with that ID, or no changes were made.";
}
} catch (PDOException $dbError) {
// Log full error internally — show nothing useful to the attackererror_log('Profile update error: ' . $dbError->getMessage());
http_response_code(500);
echo'An error occurred. Please try again.';
}
?>
Output
// Layer 1: Input validation rejects bad data before it reaches the DB
// Layer 2: Prepared statements prevent injection at DB layer
// Layer 3: htmlspecialchars() prevents XSS when rendering to HTML
// Layer 4: DB user has only UPDATE permission on profile columns
// All four layers must be present -- one does not substitute for another
Interview Gold:
Interviewers love asking 'Is input sanitisation enough to prevent SQL injection?' The answer is no — and explaining why earns serious marks. Sanitisation reduces junk data; prepared statements are what make injection impossible. They operate at different layers for different purposes. Knowing this distinction separates junior from intermediate developers.
Production Insight
Defence in depth means: prepared statements prevent injection, htmlspecialchars() prevents XSS, input validation rejects malformed data, and a least-privilege DB user limits blast radius if something does get through.
A DB user with only SELECT/INSERT/UPDATE on specific tables cannot DROP tables or exfiltrate the entire database even if injection occurs.
Audit your application DB user privileges — most applications grant far more than they need.
Key Takeaway
Prepared statements prevent injection — htmlspecialchars() prevents XSS — these are separate layers, both required.
The database user should have only the permissions the application actually needs — not db_owner or root.
Defence in depth: assume one layer will fail, design so the next layer contains the damage.
● Production incidentPOST-MORTEMseverity: high
A Login Bypass Exposed 340,000 User Records via Classic SQL Injection
Symptom
The security team noticed unusual login activity — an account with no password history making 3 login attempts then accessing admin functions. Database query logs showed UNION SELECT statements appearing in the username field.
Assumption
The authentication code had input sanitisation with addslashes() and a length check — the developer assumed this was sufficient.
Root cause
addslashes() escapes single quotes with a backslash but fails against multi-byte character encoding attacks and does not handle all injection vectors. The attacker used a UNION SELECT payload that bypassed the character escaping through a GBK encoding trick. The query was built with string concatenation: $query = "SELECT * FROM users WHERE username='$username'";
Fix
Migrated all authentication queries to PDO prepared statements with ATTR_EMULATE_PREPARES set to false. Added input validation before the database layer. Rotated all credentials and audited for exfiltration using query logs.
Key lesson
addslashes() and mysqli_real_escape_string() are not injection-proof — they are workarounds, not fixes
Prepared statements are the only solution that makes injection structurally impossible
Set PDO::ATTR_EMULATE_PREPARES to false — the default still builds SQL strings in PHP
Production debug guideIdentifying and fixing injection vulnerabilities in PHP code4 entries
Symptom · 01
Code uses PDO prepared statements but security scanner still flags injection risk
→
Fix
Check PDO::ATTR_EMULATE_PREPARES — if set to true (the PHP default), PDO builds SQL strings in PHP before sending. Set it to false: $pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false). Also check for any query() or exec() calls with string concatenation alongside your prepared statement code.
Symptom · 02
Dynamic ORDER BY or column names built from user input — cannot use prepared statement placeholders
→
Fix
Prepared statement placeholders cannot be used for column names or ORDER BY direction. Use a whitelist: $allowed = ['name', 'created_at', 'price']; if (!in_array($col, $allowed)) die('Invalid column'); then interpolate the whitelisted value. Never interpolate raw user input for structural SQL elements.
Symptom · 03
Stored XSS attack through database — malicious scripts stored and rendered to other users
→
Fix
SQL injection prevention and XSS prevention are separate layers. Prepared statements prevent injection at write time. htmlspecialchars($value, ENT_QUOTES, 'UTF-8') prevents XSS at render time. Both layers are required — one does not substitute for the other.
Symptom · 04
LIKE query with prepared statements not working as expected
→
Fix
Add % wildcards to your PHP variable before binding, not inside the SQL template. $searchTerm = '%' . $userInput . '%'; $stmt->bindParam(':term', $searchTerm); The placeholder binds the full value including wildcards — do not write LIKE '%?%'.
Approach
Injection Safe?
XSS Safe?
Notes
Raw string concatenation
No
No
Never use — any user input breaks it
addslashes() / mysql_escape_string()
Mostly no
No
Deprecated, encoding bypasses exist
mysqli_real_escape_string()
Mostly no
No
Fails on encoding attacks and numeric contexts
PDO::quote()
Mostly yes
No
Better than escaping, but not as safe as prepared statements
PDO prepared statements (emulated=false)
Yes
No
Correct fix for injection — htmlspecialchars() still needed for output
MySQLi bind_param()
Yes
No
Correct fix — verify type string matches column types
Prepared statements + htmlspecialchars() + validation + least privilege
Yes
Yes
Production-grade defence in depth
Key takeaways
1
SQL injection works by breaking out of string delimiters
any code building queries with string concatenation is vulnerable regardless of escaping.
2
Prepared statements make injection structurally impossible by separating SQL structure from data at the protocol level.
3
PDO::ATTR_EMULATE_PREPARES must be explicitly set to false
the PHP default still concatenates in PHP and reintroduces vulnerability.
4
Defence in depth
prepared statements (injection), htmlspecialchars() (XSS), input validation (business logic), least-privilege DB user (blast radius reduction) — all four layers are required.
Common mistakes to avoid
3 patterns
×
Setting PDO::ATTR_EMULATE_PREPARES to true (or leaving it as the default)
Symptom
Code appears to use prepared statements but PDO is still building the SQL string in PHP and sending it as one piece — a security scanner or code review identifies the vulnerability
Fix
Explicitly set PDO::ATTR_EMULATE_PREPARES to false in your PDO constructor options array. This forces real parameterised queries where the SQL template and data are sent separately to the database server.
×
Forgetting htmlspecialchars() when rendering database output to HTML
Symptom
An attacker stores a malicious script tag as their username or bio. When other users view the profile, the script executes in their browser — stored XSS attack
Fix
Always wrap any database value rendered to HTML: echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8'). SQL injection prevention and XSS prevention are separate layers — one does not substitute for the other.
×
Building dynamic column names or ORDER BY clauses from user input without whitelisting
Symptom
A prepared statement placeholder cannot be used for column names or ORDER BY direction — the code falls back to string interpolation for these structural elements, reintroducing injection
Fix
Use a whitelist array: $allowed_cols = ['name', 'price', 'created_at']. Validate that $sortCol is in the whitelist before interpolating. Never interpolate raw user input for SQL structural elements (column names, table names, ORDER BY).
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between parameterised queries and input sanitisat...
Q02SENIOR
If you use PDO prepared statements, do you still need to validate and sa...
Q03SENIOR
A colleague uses $pdo->query("SELECT * FROM products WHERE category = '"...
Q01 of 03SENIOR
What is the difference between parameterised queries and input sanitisation, and why is sanitisation alone insufficient?
ANSWER
Input sanitisation attempts to remove or escape dangerous characters from user input before building the SQL string. It is insufficient because it operates on a string that is later interpreted as SQL — there are always edge cases (multi-byte encoding attacks, numeric contexts, structural elements like column names) where sanitisation can be bypassed. Parameterised queries (prepared statements) work differently: they send the SQL template to the database first, let the server parse and compile it, then send the data values separately. By the time the data arrives, the query structure is already locked — the data is always treated as a value, never as SQL syntax. Injection is architecturally impossible regardless of the data content.
Q02 of 03SENIOR
If you use PDO prepared statements, do you still need to validate and sanitise user input?
ANSWER
Yes, for different reasons. Prepared statements prevent SQL injection — they do not validate business logic or prevent XSS. You still need: input validation to enforce business rules (email format, integer ranges, string length limits) before the data reaches the database; htmlspecialchars() when rendering database values to HTML to prevent stored XSS; and type checking to ensure the data makes sense for the column (passing a string where an integer is expected causes data corruption, not injection). Think of it as separate layers: prepared statements = database safety, input validation = business logic safety, output encoding = HTML rendering safety.
Q03 of 03SENIOR
A colleague uses $pdo->query("SELECT * FROM products WHERE category = '" . $pdo->quote($category) . "'"). They say PDO::quote() makes it safe. Are they right?
ANSWER
PDO::quote() is better than raw string concatenation but is not equivalent to prepared statements and has edge cases. It relies on properly escaping the value before embedding it in SQL — this can fail against some multi-byte character encodings if the connection character set is not properly configured. More importantly, it builds the final SQL string in PHP before sending to the database, which means the database's parameterisation is bypassed entirely. A prepared statement sends the template and data separately at the protocol level, making injection structurally impossible regardless of the input content. The correct version: $stmt = $pdo->prepare('SELECT * FROM products WHERE category = :cat'); $stmt->execute([':cat' => $category]);
01
What is the difference between parameterised queries and input sanitisation, and why is sanitisation alone insufficient?
SENIOR
02
If you use PDO prepared statements, do you still need to validate and sanitise user input?
SENIOR
03
A colleague uses $pdo->query("SELECT * FROM products WHERE category = '" . $pdo->quote($category) . "'"). They say PDO::quote() makes it safe. Are they right?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
Do PHP prepared statements completely prevent SQL injection?
Yes — when used correctly with native prepared statements (not PDO's emulated mode), injection is structurally impossible. The query template and user data are sent to MySQL in separate protocol messages, so user data can never be interpreted as SQL syntax. The caveat is dynamic identifiers like column names, which can't be parameterised and must be validated via an allowlist instead.
Was this helpful?
02
Is PDO better than MySQLi for security?
Both are equally secure when used correctly with real prepared statements. PDO is generally preferred for new projects because it's database-agnostic and has a cleaner API. MySQLi is perfectly fine for MySQL-specific work or legacy codebases. The security difference comes from how you use them, not which one you pick — just remember to disable emulated prepares in PDO.
Was this helpful?
03
Can I use mysqli_real_escape_string() instead of prepared statements?
You shouldn't rely on it as your primary defence. While it escapes common dangerous characters, it requires the database connection to be active first, it's charset-sensitive (certain encodings can bypass it), and it's easy to forget a single call and leave a vulnerability. Prepared statements are the correct solution — they eliminate the attack vector entirely rather than trying to sanitise around it.