05 · Security & SQL Injection¶
SQL injection happens when untrusted input is concatenated directly into a SQL string instead of being passed as data. This module demonstrates a real injection against a real login query, then the fix — parameterized queries — proven against the exact same attack string.
Sample schema¶
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, is_admin INTEGER)")
conn.executemany("INSERT INTO users VALUES (?,?,?,?)", [
(1, 'alice', 'hunter2', 0),
(2, 'admin', 's3cret', 1),
])
conn.commit()
The vulnerable version: string concatenation¶
def login_vulnerable(username, password):
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
print("QUERY:", query)
return conn.execute(query).fetchall()
print(login_vulnerable("alice", "hunter2"))
QUERY: SELECT * FROM users WHERE username = 'alice' AND password = 'hunter2'
[(1, 'alice', 'hunter2', 0)]
Looks fine with normal input. The problem is that username and password
become literal SQL text, not values — anything a caller passes in becomes
part of the query itself.
The exploit, actually run¶
QUERY: SELECT * FROM users WHERE username = 'admin'--' AND password = 'wrongpassword'
[(2, 'admin', 's3cret', 1)]
The attacker logged in as admin without knowing the password.
admin'-- closes the username string early with ', then -- starts a
SQL comment that swallows the rest of the line — including the entire
AND password = '...' check. The database receives, and executes:
Everything after -- is a comment, so the password check never happens at
all. This is the classic "comment out the rest of the query" injection —
one of dozens of shapes injection can take (others: ' OR '1'='1 to match
every row, '; DROP TABLE users;-- to run a second destructive statement).
The fix: parameterized queries¶
def login_safe(username, password):
query = "SELECT * FROM users WHERE username = ? AND password = ?"
return conn.execute(query, (username, password)).fetchall()
print(login_safe("admin'--", "wrongpassword"))
Same malicious input, empty result — login correctly rejected. The ?
placeholders tell SQLite's driver "these values are data, never SQL
syntax" — the ' and -- in the attacker's string are treated as literal
characters to compare against, not as characters that alter the query
structure. This is true regardless of database — Postgres uses %s or
$1, MySQL uses %s, SQLite uses ? — the mechanism (send SQL and values
separately, let the driver bind them) is universal and is the fix for
injection, not an optional best practice.
Why "just escape the quotes" isn't the fix¶
A tempting-but-wrong fix is manually escaping quotes (replacing ' with
'') before concatenating. This is fragile: it's easy to miss an edge
case (backslash handling, encoding tricks, numeric fields that don't get
quoted at all so 1 OR 1=1 needs no quote-escaping whatsoever), and every
place in the codebase that builds SQL has to remember to do it correctly,
every time. Parameterization removes the entire class of bug instead of
patching individual symptoms of it.
Other SQL-level security practices¶
- Least privilege — a web app's database user should only have the
permissions it actually needs (no
DROP TABLErights for a read-mostly reporting account); SQLite itself has no user/permission system since it's embedded, but the application connecting to it should still run with restricted file permissions on the.dbfile. - Never build identifiers (table/column names) from user input, even
with parameters —
?placeholders only work for values, not for table or column names, since those are part of the query's structure. If a table name must be dynamic, validate it against a fixed allow-list before use. - Don't leak query errors to end users — the error message from a failed query (as printed above) is exactly what an attacker uses to refine an injection attempt; log detailed errors server-side, show users a generic message.
- Validate input types at the boundary — expecting an integer ID and
receiving
"1; DROP TABLE users"should fail type validation before it ever reaches a query, as a second layer behind parameterization.
Cheat sheet¶
| Practice | Vulnerable | Safe |
|---|---|---|
| Building a query | f-string / string concatenation with input | ? placeholders + execute(query, params) |
| Table/column names from input | Concatenated directly | Validate against an allow-list — parameters can't cover identifiers |
| Error handling | Query errors shown to the user | Generic user-facing message, detailed log server-side |
| DB account permissions | App connects as an admin/superuser | App connects with only the privileges it needs |
| Input validation | Trust the input's shape | Validate type/format before it reaches SQL |
How It Actually Works¶
SQL injection happens because string concatenation blurs the line between
code (the SQL statement) and data (user input) before the engine ever
sees either — by the time your concatenated string reaches SQLite, the
parser has no way to know which characters were meant as literal data and
which were meant as syntax, because they're indistinguishable in the final
text. Parameterized queries (? placeholders bound via the driver's API)
fix this at a completely different layer: the SQL text is compiled into VDBE
bytecode first, with the placeholder becoming a dedicated "load a bound
parameter into this register" opcode — the actual value you bind afterward
is inserted directly as a typed value into that register, never re-parsed as
SQL text at all. This is why parameterization isn't just "safer
escaping" — the user's input structurally cannot become new SQL syntax,
because parsing already finished before the value exists in the plan. It's
also why prepared statements are faster for repeated execution: the same
compiled bytecode program is reused across calls, with only the bound
register values changing, skipping the parse/plan step entirely on
subsequent runs.
Exercise¶
- Using the
login_vulnerablefunction above, craft an injection string forusernamethat would return every row inusersregardless of password (hint: make theWHEREclause always true). - Rewrite a hypothetical
search_products(keyword)function that currently doesf"SELECT * FROM products WHERE name LIKE '%{keyword}%'"to use a parameterized query instead, keeping theLIKEwildcard behavior intact. - Explain why parameterizing a table name (e.g.
conn.execute("SELECT * FROM ?", (table_name,))) doesn't work, and describe the allow-list approach you'd use instead if the table genuinely needs to be dynamic.