Home PHP SQL Injection Prevention in PHP: Prepared Statements, PDO & Real Fixes

SQL Injection Prevention in PHP: Prepared Statements, PDO & Real Fixes

In Plain English 🔥
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.
⚡ Quick Answer
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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
<?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);
?>
▶ Output
// With normal input:
// 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.
⚠️
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.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
<?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.";
}
?>
▶ Output
// With normal credentials (alice@forge.io / correctpassword):
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.
🔥
Why `PDO::ATTR_EMULATE_PREPARES => false` Matters: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.

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.

secure_user_search_mysqli.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
<?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();
?>
▶ Output
// With username=alice&min_age=21:
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.
⚠️
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.

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.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
<?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.';
}
?>
▶ Output
// With valid input (user_id=42, username=alice_dev, email=alice@forge.io, age=28):
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.
🔥
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.
AspectPDOMySQLi
Database supportMySQL, PostgreSQL, SQLite, MSSQL, and moreMySQL / MariaDB only
Prepared statement styleNamed (:param) or positional (?) placeholdersPositional (?) placeholders only
Type binding requiredNo — PDO infers types automaticallyYes — explicit type string ('sids') in bind_param()
Emulated prepares riskMust set ATTR_EMULATE_PREPARES => false manuallyAlways uses real prepared statements
Async queriesNo native supportYes — mysqli_poll() for async queries
Object / Procedural APIObject-oriented onlyBoth OO and procedural APIs available
Error handlingExceptions (PDOException) when ERRMODE_EXCEPTION setManual error checking or exceptions with MYSQLI_REPORT_ERROR
Best forNew projects, multi-DB, clean OOP codebasesLegacy 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 set PDO::ATTR_EMULATE_PREPARES => false in 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 stores as 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 in htmlspecialchars($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 $sortColumn where $sortColumn comes 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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousCRUD with PHP and MySQLNext →PHP and MongoDB
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged