What Is SQL Injection and How It Works
A practical walkthrough of SQL injection — what causes it, how attackers exploit it, and the parameterised-query pattern that kills it for good.
SQL injection is the oldest entry in the OWASP Top 10 that refuses to die. Two decades after Bobby Tables, I still find it weekly on real engagements — usually in legacy admin panels, internal back-office tools, or that one PHP file nobody has touched since 2014.
The mechanic is embarrassingly simple. An application builds a database query by concatenating user-controlled strings into SQL syntax. The database engine has no way to tell which characters were meant as data and which were meant as commands, so it executes everything as code. Whoever controls the input controls the query.
How a Vulnerable Query Looks in the Wild
Here is the canonical anti-pattern, in a PHP login form that a junior developer would have written in 2008 and that you can still find running today:
$user = $_POST['user'];
$pass = $_POST['pass'];
$sql = "SELECT id FROM users WHERE username = '$user' AND password = '$pass'";
$result = mysqli_query($conn, $sql);
If a normal user types alice / hunter2, the query is well-formed. If an attacker types admin' -- into the username field, the resulting query becomes:
SELECT id FROM users WHERE username = 'admin' --' AND password = ''
The -- token starts a SQL comment, so everything after it is ignored — including the password check. The attacker logs in as admin without knowing the password.
This is the simplest payload. The next step up is a UNION-based payload that exfiltrates data from other tables:
' UNION SELECT username, password FROM users --
And the meanest variant, blind boolean-based injection, doesn't even need the page to render data. It infers one bit at a time from response timing or response length:
' AND IF(SUBSTRING((SELECT password FROM users WHERE id=1),1,1)='a', SLEEP(2), 0) --
A patient script can recover an entire password hash one character at a time. The first time I pulled an admin hash out of a production system using nothing but a stopwatch and a time.sleep() call, I knew this class of bug was here to stay.
The Fix That Actually Works
There is only one fix you should be writing in new code: parameterised queries, also called prepared statements. The database driver sends the query template and the parameter values down two separate channels. The parameter values can never be reinterpreted as SQL syntax because the engine already knows where the SQL ends and the data begins.
The PHP example above becomes:
$stmt = $conn->prepare(
"SELECT id FROM users WHERE username = ? AND password = ?"
);
$stmt->bind_param("ss", $user, $pass);
$stmt->execute();
$result = $stmt->get_result();
The same idea in Node.js with the mysql2 driver:
const [rows] = await conn.execute(
'SELECT id FROM users WHERE username = ? AND password = ?',
[user, pass]
);
And in Python with psycopg2:
cur.execute(
"SELECT id FROM users WHERE username = %s AND password = %s",
(user, password),
)
Three different stacks, one pattern. Whatever ORM or driver you reach for, the rule is the same: never build queries by string concatenation, ever, even when "the value comes from a trusted source." That trusted source will leak one day.
Two further hardening measures are worth knowing about. First, a least-privilege database user — the account your application connects with should not own the schema and should not be able to drop tables, run SELECT INTO OUTFILE, or read the mysql.user table. Second, a WAF or query allowlist at the edge catches blunt payloads, but never trust it as your only defence. WAFs are speed bumps; parameterised queries are walls.
Practical Takeaways
If you remember three things from this article, make them these:
- Parameterise every query. Not just the ones with "user input." Every query. ORMs do this for you by default, raw drivers don't — read your own code and check.
- Hash passwords before they ever touch the SQL layer. Even in the vulnerable example above, the real damage is that the password is stored in plain text. Use bcrypt or Argon2.
- Pentest your own forms. A 10-minute manual test with
',' OR 1=1 --, and' UNION SELECT NULL --on every input field catches more than most automated scanners. If you want a head start, try the SQLi cheatsheet inside this site.
SQL injection is a solved problem in theory and a permanent fixture in practice. The difference between the apps where it shows up and the apps where it doesn't is almost never knowledge — it's discipline. Build the habit once and it stays with you.
Related articles
Auditing a WordPress Site in 30 Minutes
WordPress runs 40 % of the web. Most of those installs have at least one critical issue. Here is the half-hour audit that finds them.
JWT Security Pitfalls: What Attackers Look For
JWTs are easy to use and even easier to misuse. The five mistakes I look for first when I see a Bearer token, and how to fix each one.
Security Headers: The Five That Actually Matter
Most security-header guides list twenty. Here are the five that actually change attacker behaviour, with the misconfigurations I see weekly.