SQL injection — how it happens, and why parameterized queries fix it
SQL injection is what happens when untrusted input gets pasted directly into a query string instead of being passed as data — the database can't tell the difference between "code the developer wrote" and "code an attacker smuggled in through a form field," and treats both as instructions.
4 min read
The vulnerability: building a query with string concatenation
username = request.form["username"] # attacker-controlled, straight from a form field
query = f"SELECT * FROM users WHERE username = '{username}'"
cursor.execute(query)If username is genuinely just a name, this works fine — SELECT * FROM users WHERE username = 'ada'. But the database has no way to know that username was meant to be a plain data value — it just receives one final string and parses the entire thing as SQL syntax, wherever that string came from. Whatever the attacker types into the username field becomes part of the actual SQL the database executes.
The exploit: input that's crafted to change the query's actual structure
username = "' OR '1'='1"
query = f"SELECT * FROM users WHERE username = '{username}'"
# Becomes: SELECT * FROM users WHERE username = '' OR '1'='1'
# '1'='1' is ALWAYS true — this returns EVERY row in the users table,
# not just a row matching a specific usernameThe attacker's input ' OR '1'='1 closes the intended string literal early with its own ', then adds OR '1'='1' — a condition that's always true — turning a query meant to check for one specific user into one that matches every row in the table. This is the entire mechanism of SQL injection: the attacker isn't "hacking" the database in some exotic sense, they're exploiting the fact that string concatenation gives them the ability to write arbitrary SQL syntax, because the database can't distinguish the developer's intended query structure from a structure the attacker smuggled in through what was supposed to be just a data value.
The fix: parameterized queries, where data and structure are genuinely separate
query = "SELECT * FROM users WHERE username = %s"
cursor.execute(query, (username,)) # username is passed SEPARATELY, never concatenated into the query stringA parameterized (or "prepared") query sends the SQL structure and the actual data value as two genuinely separate things to the database — the database parses SELECT * FROM users WHERE username = %s as a fixed query shape first, then substitutes username's actual value into the %s placeholder strictly as data, never as SQL syntax to be parsed. Even if username literally contains the string ' OR '1'='1, it's treated as a literal string to search for — a username nobody actually has — not as SQL code, because the database never re-parses it as part of the query's structure at all. This is a structural fix, not a filtering trick: there's no clever input that can escape this separation, because the separation happens at the protocol level, before the value is ever combined with the query text.
Why "just escape the quotes" is a real, common trap
# A TEMPTING but genuinely incomplete fix — manually escaping quote characters
username = username.replace("'", "''")
query = f"SELECT * FROM users WHERE username = '{username}'"
# Blocks the SPECIFIC attack above, but SQL has other syntax attackers can
# exploit that manual escaping is easy to miss (comments, encoding tricks,
# database-specific syntax quirks) — and every new query in the codebase
# needs the SAME escaping applied correctly, every single time, by handManually escaping special characters can block a specific known attack, but it requires getting the escaping exactly right for every database's specific syntax quirks, remembering to apply it at every single place a query is built, and never missing an edge case — a real, ongoing maintenance burden with a real cost when it's missed even once. Parameterized queries eliminate the entire category of risk structurally, at the library/database level, rather than relying on a developer correctly re-implementing input sanitization by hand in every single query across an entire codebase.
ORMs don't automatically make this problem disappear
# An ORM's query builder is parameterized automatically:
User.objects.filter(username=username) # SAFE — the ORM parameterizes this internally
# But RAW SQL passed through an ORM is JUST AS VULNERABLE as raw string concatenation:
User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'") # STILL VULNERABLEUsing an ORM's normal query-building methods (.filter(), .where()) is safe by default, since the ORM constructs parameterized queries internally without the developer needing to think about it — but the moment raw SQL is written and passed through the ORM's "raw query" escape hatch, string concatenation is just as dangerous as it would be without an ORM at all. The safety an ORM provides is a property of how it builds queries, not something that magically applies to every possible way of using it.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does string concatenation like `f"SELECT * FROM users WHERE username = '{username}'"` create a vulnerability?
2. Why do parameterized queries fix SQL injection structurally, rather than just filtering known-bad input?
3. Why is raw SQL passed through an ORM's escape hatch just as vulnerable as string concatenation without an ORM?