Originally publish on medium
Detection tells you something happened. Prevention stops it from mattering. Here is the prevention layer.
In the last three articles I wrote about detecting SQL injection attempts in PHP logs, why URL encoding blinds most pattern matching checks, and why unlimited URL decoding creates its own vulnerability. Every article ended with the same honest disclaimer.
"The real fix lives at the query layer, not the detection layer."
This is that article.
Why Detection Is Not Enough
Everything I have written so far the log analysis, the urldecode() fix, the capped decode loop is detection. It tells you a suspicious request arrived. It logs it. It flags it. In some cases it blocks it.
But detection has a ceiling.
A sufficiently sophisticated attacker who understands your detection layer can craft payloads that slip through. New encoding techniques. Fragmented payloads. Timing-based injection that never appears in a single suspicious request. Detection is valuable precisely because prevention is never perfect. But prevention should always come first.
For SQL injection, prevention has one name parameterized queries.
What SQL Injection Actually Is
Before explaining the fix, it helps to understand exactly what the attack exploits. SQL injection works because of one fundamental mistake treating user input as part of a SQL command.
Here is the vulnerable pattern.
$id = $_GET['id'];
$query = "SELECT * FROM users WHERE id = " . $id;
$result = $pdo->query($query);
When a legitimate user sends id=1, the query becomes.
SELECT * FROM users WHERE id = 1
Clean. Expected. Safe.
When an attacker sends id=1 OR 1=1, the query becomes.
SELECT * FROM users WHERE id = 1 OR 1=1
Which returns every row in the users table. Every single one.
The database cannot tell the difference between the developer's intended SQL and the attacker's injected SQL because they arrived as the same string. The input and the command are mixed together. That is the vulnerability.
What Parameterized Queries Actually Do
Parameterized queries also called prepared statements solve this by separating the SQL command from the user input completely.
Here is the same query written correctly.
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);
$result = $stmt->fetchAll();
What happens here is fundamentally different from string concatenation.
The SQL command SELECT * FROM users WHERE id = ? is sent to the database first, as a complete instruction with a placeholder. The database parses it, understands its structure, and prepares to execute it.
Then the user input whatever $_GET['id'] contains is sent separately as data. The database treats it as a value to fill the placeholder, not as part of the SQL command itself.
So when an attacker sends 1 OR 1=1, the database receives it as a literal string value to compare against the id column not as SQL to execute. The injection attempt becomes meaningless. The query looks for a row where id equals the literal string "1 OR 1=1" finds nothing and returns an empty result. No data leaks. No error. No attack.
What Developers Get Wrong
Parameterized queries sound simple. In practice, developers make four common mistakes that leave them thinking they are protected when they are not.
Mistake 1 - Mixing parameterized queries with string concatenation
// This is NOT safe
$table = $_GET['table'];
$stmt = $pdo->prepare("SELECT * FROM " . $table . " WHERE id = ?");
$stmt->execute([$_GET['id']]);
The id parameter is protected. The table name is not. An attacker sends table=users; DROP TABLE users-- and the table name gets concatenated directly into the SQL command.
Parameterized queries protect values. They do not protect identifiers like table names and column names. Those must be validated against a whitelist separately.
Mistake 2 - Using emulated prepares without knowing it
PDO has two modes: real prepared statements and emulated prepared statements. Emulated prepares are the default in some configurations.
// Disable emulated prepares
$pdo = new PDO($dsn, $user, $pass);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
Native prepared statements are generally preferred because the database receives the SQL statement and parameters separately. Emulated prepares still protect against SQL injection in normal use, but native prepares avoid certain edge cases and more closely match the database's own parsing behavior.
Mistake 3 - Assuming Laravel's ORM makes every query safe
Laravel's Eloquent ORM uses parameterized queries by default. But raw query methods bypass this protection.
// Safe - Eloquent uses parameterized queries
User::where('id', $id)->first();
// NOT safe - raw query with string concatenation
DB::select("SELECT * FROM users WHERE id = " . $id);
// Safe - raw query with parameter binding
DB::select("SELECT * FROM users WHERE id = ?", [$id]);
Using an ORM does not automatically make every database query safe. Raw query methods require the same discipline as plain PDO.
Mistake 4 - Thinking input validation replaces parameterized queries
Some developers validate input checking that an id is numeric, for example and believe this makes string concatenation safe.
// This is still dangerous
if (is_numeric($_GET['id'])) {
$query = "SELECT * FROM users WHERE id = " . $_GET['id'];
}
Input validation reduces the attack surface. It does not eliminate it. Edge cases in validation logic, type juggling in PHP, and unexpected input formats can all bypass validation in ways that would not bypass a parameterized query.
Parameterized queries and input validation are complementary. Validation rejects obviously bad input early. Parameterized queries make SQL injection through parameter values structurally impossible when used correctly. Use both.
The Complete Safe Pattern
Here is what correct SQL query handling looks like in PHP using PDO
// Establish connection with emulated prepares disabled
$pdo = new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
$username,
$password,
[
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]
);
// Validate input type where appropriate
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id === null) {
http_response_code(400);
exit;
}
// Use parameterized query
$stmt = $pdo->prepare("SELECT id, username, email FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch();
And in Laravel using Eloquent
// Safe - always use Eloquent methods or parameter binding
$user = User::find($id);
// Or with query builder binding
$user = DB::table('users')->where('id', '=', $id)->first();
// Never do this
$user = DB::select("SELECT * FROM users WHERE id = " . $id);
Where Detection Still Matters
Parameterized queries make SQL injection through parameter values structurally impossible when used correctly. But they do not protect against.
Queries written incorrectly by a developer who forgot to use them
Legacy code in a codebase you inherited
Third-party packages with their own database queries
Second-order injection where stored data is later used unsafely
This is why detection still has a role. Not as the primary defense as the safety net that catches what prevention misses.
A security layer that monitors incoming requests, flags suspicious patterns, and alerts you when injection attempts are arriving gives you visibility into attacks that might find a gap in your prevention layer. You fix the gap. The detection layer told you where to look.
Prevention first. Detection as the safety net. That combination is what genuine defense looks like.
The Series So Far
This is the fourth article in a series on PHP application security:
Article 1: What your PHP logs actually look like during a SQL injection attack
Article 2: Why URL encoding can break PHP security checks
Article 3: The decode bomb problem why unlimited URL decoding can be its own vulnerability
Article 4: This article parameterized queries and what developers get wrong about them
Each one builds on the last. Detection, normalization, resource safety, prevention. The full picture of what it takes to defend a PHP application in production.
The Direction Behind Kriosa
One of the design principles behind Kriosa is that detection and prevention work together not as alternatives but as layers. The middleware layer monitors and flags. Your application layer prevents with parameterized queries and input validation. The XAI dashboard explains what the detection layer found so you can close gaps in the prevention layer faster.
Neither layer replaces the other. Together they cover what either would miss alone.
Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php
Built by a developer from Cameroon, for developers who want to understand their security not just outsource it.
Sleep better we're awake - kriosa
Top comments (0)