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.

Developer's template "...WHERE name = '" User input ' OR '1'='1 Concatenated string WHERE name = ' ' OR '1'='1 ' Database parses it all as SQL
The database receives one blob of text. The quote in the user's input closes the string early; everything after it is read as SQL code.

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.

Bypass filter OR 1=1 Read all rows dump a table Cross-table read UNION SELECT Write / RCE DROP, stacked queries
One injectable parameter is rarely "just" a data leak. It is a foothold that escalates.

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.

✗ String concatenation query = "... WHERE name = '" + input + "'" DB receives: ...WHERE name = '' OR '1'='1' Structure and value arrive fused. The DB parses the attacker's quote as syntax. ✓ Parameterized query 1. DB parses: ... WHERE name = ? 2. Bind value: "' OR '1'='1" → bound to ? Parsing is done before the value shows up. The value can only be data.
Same user input, two outcomes. The only difference is whether the value was concatenated or bound.

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.

IN-BAND — answer comes back on the same screen Error-based DB error text leaks table & column names directly into the HTTP response. Union-based UNION SELECT appends attacker rows to the page's normal result set. BLIND — no data returned, answer is inferred Boolean-based Page changes for "... AND 1=1" vs "1=2". Data read one true/false question at a time. Time-based "... AND SLEEP(5)" — a slow response means the condition was true. Works with zero output. OUT-OF-BAND — data exfiltrated over another channel (DNS/HTTP) when the app is fully silent
Blind and out-of-band variants need no error messages and no visible output — which is why suppressing errors is not protection.

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=10 with ?id=10 AND 1=1 and ?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. sqlmap automates all of the above; static analysers (Semgrep, PHPStan rules, bandit for 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.

core 1 Parameterized queries / prepared statements 2 ORM or query builder as the default path 3 Allow-list validation for identifiers & enums 4 Least-privilege DB account (no DROP, scoped grants) 5 WAF, logging, anomaly alerts, code review
Only layer 1 removes the bug. Layers 2–5 decide how bad it is when someone forgets layer 1.

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

  1. Never build a query with string concatenation or interpolation. If you see a variable inside a SQL string, stop.
  2. Use placeholders for every value — ?, $1, :name — and pass data as separate arguments.
  3. Allow-list anything that can’t be a placeholder — sort columns, table names, ASC/DESC, LIMIT when dynamic.
  4. Give the app a least-privilege database account. Separate accounts per service. No schema rights in production.
  5. 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.