SQL Injection Prevention in PHP: Prepared Statements, PDO & Real Fixes
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.
<?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 admin echo "Logged in as: " . $user['email'] . " (role: " . $user['role'] . ")"; } else { echo "Invalid credentials."; } mysqli_close($connection); ?>
// Logged in as: alice@example.com (role: user)
// With injected input (' OR '1'='1 as email, anything as password):
// Logged in as: admin@forge.io (role: admin)
// ^ Attacker just became admin without knowing any password.
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.
<?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 errors PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // Return rows as associative arrays PDO::ATTR_EMULATE_PREPARES => false, // Use REAL prepared statements, not emulated ones ]; try { $pdo = new PDO($dsn, $dbUser, $dbPass, $options); } catch (PDOException $connectionError) { // Log the real error internally — never expose DB details to the user error_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 LIMIT 1"; // --- 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 hash echo "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 wrong echo "Invalid email or password."; } ?>
Welcome back, alice@forge.io! Your role is: editor
// With injection attempt (' OR '1'='1 as email):
Invalid email or password.
// ^ The injection is completely inert. MySQL looked for a user
// whose email is literally the string " ' OR '1'='1 " — found nobody.
MySQLi Prepared Statements and Binding — When You're Working With MySQLi Codebases
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.
<?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 = new mysqli($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 >= ? ORDER BY username ASC LIMIT 50"; // 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 HTML echo "- " . htmlspecialchars($member['username']) . " (" . htmlspecialchars($member['email']) . ")" . " Age: " . (int)$member['age'] . "\n"; } } // Always close the statement and connection $statement->close(); $mysqli->close(); ?>
Found 2 member(s):
- alice_dev (alice@forge.io) Age: 28
- alice_admin (alice2@forge.io) Age: 34
// With injection attempt: username=' OR 1=1; --&min_age=0
Found 0 member(s):
// ^ MySQLi looked for a username containing the literal string
// "' OR 1=1; --" — no match. Injection completely neutralised.
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.
<?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 happens http_response_code(422); foreach ($validationErrors as $error) { // htmlspecialchars() here because we're outputting to HTML echo htmlspecialchars($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 = new PDO($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 XSS echo "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 attacker error_log('Profile update error: ' . $dbError->getMessage()); http_response_code(500); echo 'An error occurred. Please try again.'; } ?>
Profile updated for: alice_dev
// With invalid email:
Invalid email address format.
// With age out of range (age=5):
Age must be between 13 and 120.
// With injection attempt in username field:
// filter_input strips HTML special chars, and the prepared statement
// treats whatever remains as a plain string value — no SQL executed.
| Aspect | PDO | MySQLi |
|---|---|---|
| Database support | MySQL, PostgreSQL, SQLite, MSSQL, and more | MySQL / MariaDB only |
| Prepared statement style | Named (:param) or positional (?) placeholders | Positional (?) placeholders only |
| Type binding required | No — PDO infers types automatically | Yes — explicit type string ('sids') in bind_param() |
| Emulated prepares risk | Must set ATTR_EMULATE_PREPARES => false manually | Always uses real prepared statements |
| Async queries | No native support | Yes — mysqli_poll() for async queries |
| Object / Procedural API | Object-oriented only | Both OO and procedural APIs available |
| Error handling | Exceptions (PDOException) when ERRMODE_EXCEPTION set | Manual error checking or exceptions with MYSQLI_REPORT_ERROR |
| Best for | New projects, multi-DB, clean OOP codebases | Legacy PHP projects, WordPress environments, MySQL-specific apps |
🎯 Key Takeaways
- Prepared statements make SQL injection structurally impossible — the query template reaches MySQL before any user data does, so the database parser has already decided what's SQL and what's data before your input arrives.
- Always set
PDO::ATTR_EMULATE_PREPARES => false— without it, PDO's default emulation mode still builds the SQL string in PHP, losing the core security guarantee of native prepared statements. - You cannot parameterise column names or SQL keywords like ORDER BY and LIMIT — only data values. For dynamic identifiers, use an explicit PHP allowlist and validate against it before concatenation.
- Defence in depth means prepared statements + input validation + htmlspecialchars() output encoding + a least-privilege database user — each layer catches what the previous one can't.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using
PDO::ATTR_EMULATE_PREPARES => true(the default) — Symptom: your code looks like it uses prepared statements, but PDO is still building the SQL string in PHP and sending it as one piece. In edge cases with certain charsets, injection is still possible. Fix: always explicitly setPDO::ATTR_EMULATE_PREPARES => falsein your connection options array. This forces native prepared statements where the query template and data travel to MySQL separately. - ✕Mistake 2: Forgetting
htmlspecialchars()when rendering database output to HTML — Symptom: an attacker storesas their username, and every page that displays usernames executes that script in visitors' browsers (stored XSS). SQL injection prevention stops the DB attack; output encoding stops the browser attack. Fix: always wrap database values inhtmlspecialchars($value, ENT_QUOTES, 'UTF-8')before echoing to HTML. - ✕Mistake 3: Building dynamic column names or ORDER BY clauses with user input — Symptom: you correctly use prepared statements for WHERE values, but then write
ORDER BY $sortColumnwhere$sortColumncomes from$_GET['sort']. Prepared statements cannot parameterise column names or SQL keywords — only data values. An attacker passes(SELECT password FROM users LIMIT 1)as the sort column and extracts data through timing. Fix: use an explicit allowlist:$allowed = ['username', 'created_at', 'age']; $sortColumn = in_array($_GET['sort'], $allowed) ? $_GET['sort'] : 'username';then concatenate only the validated value.
Interview Questions on This Topic
- QWhat's the difference between parameterised queries and input sanitisation, and why is sanitisation alone not sufficient to prevent SQL injection?
- QIf I use PDO with prepared statements, do I still need to validate and sanitise user input? Why or why not?
- QA colleague shows you a query: `$pdo->query("SELECT * FROM products WHERE category = '" . $pdo->quote($category) . "'")`. They say `PDO::quote()` makes it safe. What's your response, and what would you use instead?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.