Home PHP SQL Injection — GBK Encoding Bypass Exposed 340k Records
Intermediate 8 min · March 06, 2026
SQL Injection Prevention in PHP

SQL Injection — GBK Encoding Bypass Exposed 340k Records

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

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 25 min
  • Solid grasp of fundamentals
  • Comfortable reading code examples
  • Basic production concepts
 ● 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
✦ Definition~90s read
What is SQL Injection Prevention in PHP?

SQL injection is a code injection technique where an attacker inserts malicious SQL statements into application queries by exploiting unsanitized user input. The core problem isn't just missing escaping — it's that raw query strings are concatenated with user data, allowing an attacker to break out of the intended SQL context and execute arbitrary commands.

Imagine you run a library.

The infamous GBK encoding bypass, which exposed 340k records, exploited a multibyte character encoding mismatch: when PHP's addslashes() or mysql_real_escape_string() runs on a GBK-encoded string, certain byte sequences (like 0xbf27) can be interpreted as a valid multibyte character, consuming the escape backslash and leaving the single quote unescaped. This isn't a theoretical edge case — it's a real-world vulnerability that has compromised production databases at scale.

Parameterized queries (prepared statements) are the only reliable defense because they separate SQL logic from data at the protocol level, not through string manipulation. With PDO or MySQLi prepared statements, the database engine receives the query template and the parameters as distinct entities — user input is never interpolated into the SQL string.

This makes injection structurally impossible regardless of encoding, character set, or input complexity. Raw query building with mysqli_query() or PDO::query() should be treated as a code smell in any modern PHP application; the GBK bypass is just one of dozens of encoding-based attacks that parameterized queries eliminate by design.

Beyond prepared statements, defense-in-depth includes input validation (type-checking, length limits, whitelist patterns), output encoding to prevent stored XSS from leaked data, and database least privilege — your application's database user should never have DROP or ALTER permissions. Tools like PHPStan, Psalm, and commercial SAST scanners can catch raw query patterns in CI.

The 340k record breach wasn't a sophisticated zero-day; it was a preventable encoding mismatch that parameterized queries would have stopped cold. If you're still building SQL strings by hand in 2024, you're not just writing legacy code — you're shipping a known vulnerability.

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.

Why Parameterized Queries Are Not Optional for SQL Injection Prevention

SQL injection prevention in PHP is the practice of ensuring user-supplied input cannot alter the intended structure of SQL queries. The core mechanic is separating SQL logic from data: using prepared statements with bound parameters (via PDO or MySQLi) so the database engine treats input as literal values, never as executable code. Without this separation, any input—like a single quote or a GBK-encoded multibyte character—can break out of its string context and rewrite the query.

Prepared statements work by sending the query template to the database first, then sending the parameters separately. This means the database already knows the query's shape before it sees any user data. Even if an attacker passes 1 OR 1=1 or a GBK byte sequence that tricks mysql_real_escape_string() into producing a valid escape character, the database never interprets that data as SQL syntax. The key property: escaping functions are context-dependent and can fail with certain character sets (e.g., GBK), but prepared statements are immune to encoding tricks.

Use prepared statements for every database query that includes user input—GET/POST parameters, headers, cookies, file names, anything. In production, a single raw query concatenated with user data is a breach waiting to happen. The 340k-record leak referenced in the title occurred because the application used mysql_real_escape_string() with a GBK connection, allowing an attacker to craft a multibyte sequence that consumed the escape character and injected a quote. Prepared statements would have prevented that entirely.

⚠ Escaping Is Not Prevention
mysql_real_escape_string() fails when the connection charset is GBK or similar multibyte encodings. Prepared statements are the only reliable defense.
📊 Production Insight
A team used mysql_real_escape_string() with SET NAMES GBK, thinking it was safe. An attacker sent a crafted byte sequence (0xbf27) that consumed the backslash and injected a single quote, leading to full table extraction.
Symptom: queries that previously worked suddenly allowed ' OR '1'='1 to return all rows, and logs showed no unusual characters because the multibyte sequence was valid UTF-8 after conversion.
Rule: never rely on escaping functions when the connection charset is multibyte; always use prepared statements with explicit charset binding.
🎯 Key Takeaway
Prepared statements (PDO or MySQLi) eliminate SQL injection entirely by separating query structure from data.
Escaping functions are context-sensitive and fail with multibyte encodings like GBK, leading to bypasses.
Every raw SQL string concatenation with user input is a potential breach—use parameterized queries as a non-negotiable standard.
sql-injection-prevention-php Defense Layers Against SQL Injection Multi-tier security architecture for database protection Input Validation Whitelist allowed characters | Reject unexpected encoding | Length limits Query Construction PDO prepared statements | MySQLi bound parameters | No string concatenation Database Access Least privilege user | Stored procedures | Read-only accounts Output Encoding HTML entity encoding | Context-aware escaping | Content Security Policy Monitoring & Audit Query logging | Anomaly detection | Regular penetration tests THECODEFORGE.IO
thecodeforge.io
Sql Injection Prevention Php

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.
sql-injection-prevention-php Raw Queries vs Prepared Statements Why parameterized queries prevent GBK encoding bypass Raw Queries (Vulnerable) Prepared Statements (Secure) Query Construction String concatenation with user input SQL template with placeholders Encoding Handling Depends on charset of connection Binary-safe, no encoding interference Escape Function Required Yes (e.g., addslashes, mysql_real_escape No (data never parsed as SQL) GBK Bypass Risk High (multi-byte chars can consume escap None (data sent separately from query) Performance Slightly faster per query (no prepare st Faster for repeated queries (cached plan Security Guarantee None (developer must escape correctly) Strong (SQL injection impossible via dat THECODEFORGE.IO
thecodeforge.io
Sql Injection Prevention Php

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.

The Hidden Danger: Dynamic Queries in ORDER BY, LIMIT, and Table Names

You think prepared statements cover everything? They don't. Parameters only work for data values — not for SQL structural elements like column names, table names, or ORDER BY directions. When you inject user input into these positions, prepared statements can't help you. The attacker owns your query structure.

The real problem: developers assume parameterization is a magic bullet. It's not. That LIMIT and OFFSET in your pagination? Still vulnerable. That sortable table column? Also vulnerable. The fix is strict whitelisting. Map user input to known valid values before it ever touches SQL. If the user wants to sort by 'email', you check: is 'email' in your approved list of column names? Yes — use it. No — default to 'id'. Same for LIMIT. Cast it to an integer. If it fails, use a sensible default.

This isn't about being clever. It's about accepting that parameterized queries have sharp edges. Know where they end and build hard checks for what's left.

safe_sorting.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge
$allowedSorts = ['id', 'name', 'email', 'created_at'];
$sort = $_GET['sort'] ?? 'id';
$sort = in_array($sort, $allowedSorts, true) ? $sort : 'id';

$allowedDirs = ['ASC', 'DESC'];
$dir = strtoupper($_GET['dir'] ?? 'ASC');
$dir = in_array($dir, $allowedDirs, true) ? $dir : 'ASC';

$stmt = $pdo->prepare("SELECT id, name, email FROM users ORDER BY {$sort} {$dir} LIMIT :limit");
$stmt->bindValue(':limit', (int) ($_GET['limit'] ?? 50), PDO::PARAM_INT);
$stmt->execute();
Output
Only whitelisted column names and directions reach the SQL. LIMIT is cast to int. No injection possible.
⚠ Production Trap:
I've seen teams add prepared statements to everything and still get popped because they concatenated user input into an ORDER BY clause. That query looked safe. It wasn't. Whitelist or die.
🎯 Key Takeaway
Prepared statements protect data values only. For SQL structure (column names, table names, LIMIT, ORDER BY), enforce strict whitelisting or cast to integers.

Defense in Depth: Why Input Validation Is Your Second Wall, Not Your First

I hear this every week: 'But we validate all inputs, so SQL injection is impossible.' Wrong. Input validation is a mitigation, not a cure. You validate for business rules, not security. The real cure is structural separation of code and data — prepared statements. Validation stops garbage. Prepared statements stop injection.

Here's the order I teach juniors: First, parameterize everything that can be parameterized. Second, validate what can't — column names, sort orders, file paths. Third, encode output for its context (HTML entities for HTML, hex for URLs). Fourth, apply least privilege to your database user. That application user does not need DROP TABLE or ALTER privileges.

Why this order? Because validation fails sometimes. A regex that allows 'Robert'); DROP TABLE Students;--' through? It happens. But if that string never becomes SQL code because you used a parameter, it's just a weird name in the database. Validation is your backup parachute, not your primary flight system. Build your layers in the right order.

layered_defense.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge
// Layer 1: Parameterized query (primary defense)
$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');
$stmt->execute([
    ':name' => $_POST['name'],
    ':email' => filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)
]);

// Layer 2: Input validation (secondary — catches garbage)
if (!filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
    log_warning('Invalid email attempt: ' . $_POST['email']);
    http_response_code(400);
    exit;
}

// Layer 3: Output encoding for display
$safeName = htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8');
echo "<div>Welcome, {$safeName}</div>";

// Layer 4: Database user has SELECT, INSERT, UPDATE only — no DROP/DELETE on app tables
Output
Even if validation fails, prepared statements prevent injection. Parameterization first, validation second.
🔥Security Engineer's Rule:
If you find yourself saying 'we validate so we're safe,' you've already lost. Validation is not a security control — it's a data quality control. Prepared statements are the security control.
🎯 Key Takeaway
Layered security: parameterization → input validation → output encoding → least privilege. Build in that order. Never rely on validation alone.

Prepared Statements: The Only Reliable Defense

Prepared statements are the gold standard for preventing SQL injection because they separate SQL logic from data. Instead of concatenating user input into a query string, you send a template to the database server, which compiles it with placeholders. Then you send the actual values separately, ensuring they are never interpreted as SQL code. This structural separation makes injection structurally impossible, regardless of encoding tricks like GBK. In PHP, both PDO and MySQLi support prepared statements. The key is to always use them for any query that includes user input, even if you think the input is safe. For example, never do this: $sql = "SELECT FROM users WHERE id = $_GET[id]"; Instead, use a prepared statement: $stmt = $pdo->prepare('SELECT FROM users WHERE id = :id'); $stmt->execute(['id' => $_GET['id']]); This simple pattern eliminates the entire class of injection vulnerabilities. Many developers mistakenly believe that escaping functions like mysqli_real_escape_string are sufficient, but they can be bypassed with certain character sets (like GBK) or edge cases. Prepared statements are the only defense that works at the database level, before any encoding or escaping is applied. They are not optional; they are mandatory for any production PHP application that interacts with a database.

prepared_statement_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
// Correct: Using PDO prepared statement
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$user = $stmt->fetch();

// Incorrect: Raw query concatenation (vulnerable)
// $sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
// $result = $pdo->query($sql);
?>
⚠ Escaping is not enough
📊 Production Insight
In production, ensure your database connection uses a safe charset like utf8mb4 and disable emulated prepares in PDO to guarantee real prepared statements are used.
🎯 Key Takeaway
Always use prepared statements for any database query that includes user input; they prevent SQL injection by design.

Input Validation Layers in Modern PHP

While prepared statements prevent SQL injection at the database layer, input validation adds a critical second line of defense. Modern PHP applications should implement multiple validation layers: client-side (JavaScript), server-side (PHP), and database-level constraints. Server-side validation is non-negotiable because client-side validation can be bypassed. Use PHP filters (filter_var, filter_input) for basic type and format checks. For complex rules, consider using validation libraries like Symfony Validator or Respect\Validation. Always validate on the server even if you trust the source. For example, validate that an email is actually an email, that an ID is numeric, and that a string length is within expected bounds. However, validation should never be used as a substitute for prepared statements; it is an additional layer. A common mistake is to validate input and then still use it unsafely in a query. Validation reduces the attack surface but does not eliminate injection risk. Combine validation with prepared statements for defense in depth. Also, validate output encoding to prevent XSS, but that's a separate concern. In summary, validate early, validate often, but never trust validation to make raw queries safe.

input_validation_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
// Server-side validation example
$email = $_POST['email'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    die('Invalid email format');
}

// Still use prepared statement even after validation
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);

// Validate numeric ID
$id = $_GET['id'] ?? 0;
if (!ctype_digit($id)) {
    die('Invalid ID');
}
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute(['id' => (int)$id]);
?>
💡Validate, don't sanitize
📊 Production Insight
Use PHP's filter functions for simple checks and consider a validation library for complex rules. Never rely on validation alone to prevent SQL injection.
🎯 Key Takeaway
Input validation is a crucial second layer of defense but must always be paired with prepared statements.

Modern Best Practices: ORMs, Query Builders, and RAW SQL

Modern PHP frameworks like Laravel, Symfony, and Doctrine provide ORMs and query builders that abstract raw SQL and automatically use prepared statements. For example, Laravel's Eloquent ORM generates parameterized queries under the hood: User::where('email', $email)->first(); This is safe because the framework handles binding. Query builders like Doctrine's DBAL also use prepared statements. However, developers sometimes fall back to raw SQL for complex queries or performance reasons. When using raw SQL with an ORM, you must still use prepared statements. For example, in Laravel, use DB::select('SELECT * FROM users WHERE email = ?', [$email]); Never concatenate. ORMs are not magic; they can be misused. For instance, using whereRaw() with concatenation is dangerous. The best practice is to use the ORM's query builder as much as possible, and when raw SQL is unavoidable, always use parameter binding. Also, be aware that some ORM features like lazy loading can generate additional queries, but those are also parameterized. In summary, ORMs and query builders are safe when used correctly, but they do not eliminate the need for understanding prepared statements. Always verify that your ORM is using parameterized queries, especially when using raw methods.

orm_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
// Laravel Eloquent (safe)
$user = User::where('email', $request->input('email'))->first();

// Laravel Query Builder (safe)
$users = DB::table('users')->where('email', $request->input('email'))->get();

// Raw SQL with binding (safe)
$users = DB::select('SELECT * FROM users WHERE email = ?', [$request->input('email')]);

// Dangerous: raw concatenation (never do this)
// $users = DB::select("SELECT * FROM users WHERE email = '" . $request->input('email') . "'");
?>
🔥ORMs are not a silver bullet
📊 Production Insight
In production, configure your ORM to throw exceptions on unsafe queries (e.g., Laravel's DB::raw() should be used sparingly).
🎯 Key Takeaway
Use ORMs and query builders for safety, but always fall back to parameterized raw SQL when needed.
● 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
⚙ Quick Reference
9 commands from this guide
FileCommand / CodePurpose
vulnerable_login.php$host = 'localhost';How SQL Injection Actually Works
secure_login_pdo.php$host = 'localhost';The Right Fix
secure_user_search_mysqli.php$host = 'localhost';MySQLi Prepared Statements and Binding
hardened_profile_update.php$userId = filter_input(INPUT_POST, 'user_id', FILTER_VALIDATE_INT);Beyond Queries
safe_sorting.php$allowedSorts = ['id', 'name', 'email', 'created_at'];The Hidden Danger
layered_defense.php$stmt = $pdo->prepare('INSERT INTO users (name, email) VALUES (:name, :email)');Defense in Depth
prepared_statement_example.php$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass...Prepared Statements
input_validation_example.php$email = $_POST['email'] ?? '';Input Validation Layers in Modern PHP
orm_example.php$user = User::where('email', $request->input('email'))->first();Modern Best Practices

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.
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?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's PHP & MySQL. Mark it forged?

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

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