SQL injection is one of the oldest and most devastating attacks against web applications; it happens when data from the user is mixed directly into a SQL query. By sending text that changes the meaning of the query instead of a normal value, an attacker can read data, modify it, or even drop entire tables. The good news: with the right patterns you can close off this entire class of attack almost completely. In this article we will see how it works with real examples and build, step by step, the primary line of defense — prepared statements (parameterized queries).
How SQL injection works
The core problem is that the code joins data and command in the same string. The database interprets the whole text it receives; it has no way to know which part is "trusted code" and which part is "user data." A classic, broken example:
// NEVER DO THIS
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$result = $db->query($sql);
If the user types a normal address there is no problem. But if the input is ' OR '1'='1, the query becomes:
SELECT * FROM users WHERE email = '' OR '1'='1'
Because '1'='1' is always true, the query returns every user. On a login screen that means signing in with no password.
Real attack examples
SQL injection does not come in a single shape. Recognizing the most common types explains why simple tricks like "stripping quotes" fall short.
- Authentication bypass: The
OR '1'='1pattern above is the best-known example on login forms. - UNION-based: The attacker appends their own
SELECTresult to the existing query. For example,' UNION SELECT username, password FROM users --can leak data from another table. - Error-based: Detailed error messages returned by the database expose the table and column structure; the attacker uses them to map the schema.
- Blind injection: Even when the application shows no errors, data can be extracted bit by bit by observing true/false responses or response timing (such as
AND SLEEP(5)).
An important point: trying to "clean" the input by stripping quotes is a fragile defense. Different databases have different escaping rules and encoding tricks; sooner or later a gap remains. The correct solution is to separate data from code entirely.
The primary defense: prepared statements
A prepared statement (parameterized query) separates the structure of the query from its data. First you prepare the query template with placeholders, then you send the values through a separate channel. Whatever the value is, the database treats it as a pure value, never a command. In PHP with PDO:
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
Here ? is a placeholder. The value you pass into execute() is inserted after the query structure is already locked; whether it contains a quote, an OR, or a -- changes nothing. Named parameters improve readability:
$stmt = $pdo->prepare(
'INSERT INTO posts (title, user_id) VALUES (:title, :user_id)'
);
$stmt->execute([':title' => $title, ':user_id' => $userId]);
If you use PDO, turn off emulation to be sure the driver uses real prepared statements:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
The same principle holds in every language. In Node.js with mysql2:
const [rows] = await conn.execute(
'SELECT id, name FROM users WHERE email = ?',
[email]
);
There is a single key rule: never glue user data into a query with string concatenation. Always pass it as a parameter.
ORMs and query builders
Modern frameworks generate parameterized queries automatically when used correctly. In Laravel, Eloquent and the query builder bind values as parameters on their own:
// Safe — value is bound automatically
User::where('email', $email)->first();
DB::table('users')->where('email', $email)->get();
But an ORM does not protect you automatically; the moment you write raw SQL, the responsibility shifts back to you. If you put user data straight into DB::raw() or a whereRaw() argument, the hole reappears. Even there, use bindings:
// Wrong: value embedded directly
DB::select("SELECT * FROM users WHERE email = '$email'");
// Right: bindings array
DB::select('SELECT * FROM users WHERE email = ?', [$email]);
How to keep table and column names safe
Placeholders only work for values; structural parts like a table name, a column name, or an ORDER BY direction cannot be parameters. ORDER BY ? will not work. If a user can choose which column to sort by, do not put the incoming value straight into the query — validate it against a fixed whitelist:
$allowed = ['name', 'created_at', 'price'];
$column = in_array($_GET['sort'], $allowed, true)
? $_GET['sort']
: 'name';
$sql = "SELECT * FROM products ORDER BY $column";
Here the user can only pick one of the pre-approved values; no other text can reach the query.
Defense in depth
Prepared statements are the primary and sufficient defense against SQL injection. But security is holistic; these layers narrow the attack surface and limit potential damage:
- Principle of least privilege: Grant the application's database user only the permissions it needs. A web app should not have
DROP TABLEorGRANTrights. - Input validation: Enforce the expected type and format (an email looks like an email, a number like a number). This alone does not prevent injection, but it shrinks the attack surface.
- Hide error messages: In production, never show raw database errors to the user; log them and return a generic message. Otherwise you invite error-based injection.
- WAF and monitoring: A web application firewall can filter known attack patterns; it is not sufficient on its own but is an extra layer.
Frequently Asked Questions
Does sanitizing input prevent SQL injection?
Not reliably on its own. Quote escaping and blacklist filters can be bypassed on different databases and through encoding tricks. The real solution is parameterized queries; sanitizing is only a complementary measure.
I use an ORM — is there still a risk?
For standard queries the ORM protects you because it binds values automatically. The risk appears where you write raw SQL (whereRaw, DB::raw, raw concatenation). Always use binding parameters in those places.
Is using a stored procedure enough?
Not on its own. If you build dynamic SQL inside a stored procedure with string concatenation, the vulnerability simply moves into the procedure. You must follow the same parameterized approach inside procedures too.
Don't leave your application's security to chance. If you want me to review an existing codebase for SQL injection or build a secure data layer, get in touch and let's lay a solid foundation.