SQL injection has been near the top of every “most dangerous web vulnerabilities” list for over two decades. It is old, well understood, and completely preventable — yet it still shows up in production code, usually for the same reason: a query was built by gluing strings together.
This note is a compact tour. By the end you should be able to explain what SQL injection is, spot the pattern that causes it, and know exactly which technique stops it.
The one-sentence definition
SQL injection happens when data supplied by a user is allowed to change the structure of a SQL query, instead of being treated purely as a value inside it.
Everything else in this article is a consequence of that sentence.
Why it happens: code and data get mixed
A SQL query has two kinds of content:
- Code — the keywords and structure:
SELECT,FROM,WHERE,AND,OR, parentheses, operators. - Data — the values: a username, an email, a product id, a search term.
When you build a query by concatenating strings, the user’s data is pasted directly into the code. The database has no way to know which characters came from you (the developer) and which came from the user. It just parses the whole thing as one SQL statement.
A concrete attack, step by step
Here is a login lookup written the dangerous way. The language is PHP, but the flaw is identical in every language and framework.
// DANGEROUS — never do this
$name = $_GET['name'];
$sql = "SELECT id, email FROM users WHERE name = '" . $name . "'";
$result = $db->query($sql);
Step 1 — the normal case
Input: alice
SELECT id, email FROM users WHERE name = 'alice'
Works fine. This is why the bug survives code review — the happy path looks correct.
Step 2 — break out of the string
Input: ' OR '1'='1
SELECT id, email FROM users WHERE name = '' OR '1'='1'
'1'='1' is always true, so the WHERE clause matches every row. The attacker just dumped the entire users table.
Step 3 — go further
Input: '; DROP TABLE users; --
SELECT id, email FROM users WHERE name = ''; DROP TABLE users; --'
The -- comments out the trailing quote so the statement stays valid. If the driver allows stacked queries, the table is gone.
Step 4 — steal data from other tables
Input: ' UNION SELECT card_number, cvv FROM payments --
SELECT id, email FROM users WHERE name = ''
UNION SELECT card_number, cvv FROM payments --'
UNION welds a second result set onto the first. The login screen now returns payment data.
The fix: parameterized queries
The cure is to send the query structure and the data to the database separately. This is called a parameterized query or prepared statement.
You send a query with placeholders:
SELECT id, email FROM users WHERE name = ?
Then you send the value ' OR '1'='1 separately. The database has already finished parsing the query structure — it knows name = ? expects exactly one value. Whatever you pass for ? is stored as that column’s value and never re-parsed as SQL. The attack string becomes a literal (nonsensical) username that matches no one.
How it looks in real code
Raw PDO (PHP):
$stmt = $db->prepare('SELECT id, email FROM users WHERE name = ?');
$stmt->execute([$_GET['name']]);
$rows = $stmt->fetchAll();
Laravel query builder — parameter binding is automatic:
$users = DB::table('users')
->where('name', $request->input('name'))
->get();
Laravel Eloquent:
$users = User::where('name', $request->input('name'))->get();
Node.js (pg):
await client.query(
'SELECT id, email FROM users WHERE name = $1',
[req.query.name]
);
Python (sqlite3 / DB-API):
cur.execute(
"SELECT id, email FROM users WHERE name = ?",
(request.args["name"],),
)
In every case the placeholder (?, $1, :name) marks a slot, and the value travels in a separate argument. That is the whole idea.
The trap: things that are not fixes
| Non-fix | Why it fails |
|---|---|
Escaping quotes by hand (str_replace("'", "''", $x)) |
Easy to get wrong; breaks on different encodings; useless for numeric contexts where no quotes are needed (id = 1 OR 1=1). |
Blocklisting words like DROP, UNION, -- |
Attackers bypass with comments, casing, encoding, whitespace tricks. Also breaks legitimate input (“I work in M&A, DROP me a line”). |
| Hiding SQL errors | Blind SQL injection needs no error messages — attackers infer data from timing or true/false responses. |
| A Web Application Firewall alone | Useful defense-in-depth, but pattern matching is bypassable. It buys time, not safety. |
addslashes() / generic escaping |
Not context-aware; documented bypasses exist. |
| ORM, used carelessly | User::whereRaw("name = '$name'") or DB::select("... $name ...") reintroduces the exact bug. The ORM only protects you when you let it bind. |
Where people still get burned even with an ORM
Parameter binding covers values. It cannot bind identifiers — table names, column names, ORDER BY columns, or ASC/DESC. Those cannot be placeholders in SQL.
// user controls the sort column — cannot be a bound parameter
$sortColumn = $request->input('sort'); // "created_at); DROP TABLE ..."
$users = User::orderBy($sortColumn)->get(); // unsafe
For identifiers, use an allow-list: map user input to a fixed set of known-good values.
$allowed = ['name', 'created_at', 'email'];
$sortColumn = in_array($request->input('sort'), $allowed, true)
? $request->input('sort')
: 'name';
$users = User::orderBy($sortColumn)->get(); // safe
Same rule for dynamic IN (...) lists, LIKE patterns (bind the value, escape % and _ if they must be literal), and any raw fragment you truly cannot avoid.
The four flavours of SQL injection
Knowing the categories helps you recognise an attack in logs and understand why “hide the error message” is not a defense.
How it gets found
Attackers and testers probe the same way, and you can run these checks against your own app:
- The single quote. Enter
'in every field and URL parameter. A 500 error, a broken page, or a SQL error string means the input reaches a query unescaped. - Boolean pair. Compare
?id=10with?id=10 AND 1=1and?id=10 AND 1=2. If the last one returns a different (or empty) page, the parameter is injectable. - Timing probe.
?id=10 AND SLEEP(5)— if the response is ~5 seconds slower, the input is being executed. - Automated scanners.
sqlmapautomates all of the above; static analysers (Semgrep, PHPStan rules,banditfor Python) flag string-built queries at build time. Wire one into CI.
Test on systems you own or are authorised to test. Unauthorised probing is illegal.
Defense in depth
Parameterized queries stop the vulnerability. The other layers limit the blast radius if something slips through — a legacy endpoint, a third-party library, a raw query added under deadline.
Least privilege matters more than people think. If the application’s database user cannot DROP, cannot read the payments table, and cannot write to users, then an injection in the product search is contained to what that account can already do. Give each service its own account with only the grants it needs.
A five-point checklist
- Never build a query with string concatenation or interpolation. If you see a variable inside a SQL string, stop.
- Use placeholders for every value —
?,$1,:name— and pass data as separate arguments. - Allow-list anything that can’t be a placeholder — sort columns, table names,
ASC/DESC,LIMITwhen dynamic. - Give the app a least-privilege database account. Separate accounts per service. No schema rights in production.
- Grep your codebase for the danger signs:
whereRaw,DB::raw,DB::select(",query("... $,execute("... " +,f"SELECT ... {. Review every hit.
Conclusion
SQL injection is not a sophisticated attack. It is the predictable result of letting user data land in the same string as SQL keywords. The database cannot tell the two apart, so it trusts all of it.
Parameterized queries fix this by design: the structure is parsed first, the values arrive after, and a value can never become code. Reach for your framework’s binding — where(), prepare(), $1 — as the default, keep a short allow-list for the parts that can’t be bound, run the app on a database account that can’t do much damage, and this entire class of vulnerability closes.