Senior 5 min · March 06, 2026

SQL Injection — GBK Encoding Bypass Exposed 340k Records

340k user records exposed via GBK encoding bypass of addslashes().

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 admin
    echo "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 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
// 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
// Login fails correctly
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.
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 = 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
// 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 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
// 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 '%?%'.
ApproachInjection Safe?XSS Safe?Notes
Raw string concatenationNoNoNever use — any user input breaks it
addslashes() / mysql_escape_string()Mostly noNoDeprecated, encoding bypasses exist
mysqli_real_escape_string()Mostly noNoFails on encoding attacks and numeric contexts
PDO::quote()Mostly yesNoBetter than escaping, but not as safe as prepared statements
PDO prepared statements (emulated=false)YesNoCorrect fix for injection — htmlspecialchars() still needed for output
MySQLi bind_param()YesNoCorrect fix — verify type string matches column types
Prepared statements + htmlspecialchars() + validation + least privilegeYesYesProduction-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.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Do PHP prepared statements completely prevent SQL injection?
02
Is PDO better than MySQLi for security?
03
Can I use mysqli_real_escape_string() instead of prepared statements?
🔥

That's PHP & MySQL. Mark it forged?

5 min read · try the examples if you haven't

Previous
CRUD with PHP and MySQL
4 / 6 · PHP & MySQL
Next
PHP and MongoDB