flawopen.com/SQL Injection/Python

SQL Injection in Python

Critical CWE-89 Draft — pending review
Language: English Deutsch Español Français हिन्दी Bahasa Indonesia 日本語 한국어 Português (Brasil) Русский 简体中文
ELI5

Imagine a form that only ever expects a ticket number, like 482. SQL injection is what happens when someone types something sneaky into that box instead of a number — a trick phrase that makes the system say "show me every ticket" instead of just ticket 482, because the system never checked that what it received was actually just a number.

Key terms on this page
user-controlled input
Any value that ultimately came from whoever is using — or attacking — the app: a form field, a URL parameter, an uploaded filename, an HTTP header. The app can't assume it's well-formed or safe.
SQL query
The command sent to a database — e.g. "get this row," "delete this table." Its meaning comes entirely from its exact text, which is what makes injecting extra text into it dangerous.

What's happening

SQL injection happens when user-controlled input gets inserted directly into a database query's text, instead of being passed as a separate value. If your code builds a query by gluing strings together, an attacker can supply input that changes the query's actual structure — turning a "look up one row" query into one that returns every row, or deletes a table.

In Python, this almost always shows up the same way: a database call built with an f-string, % formatting, or plain + concatenation instead of the database driver's built-in parameter placeholders.

Real-world impact

In 2015, UK telecom TalkTalk suffered a breach affecting over 150,000 customers after attackers exploited a SQL injection flaw in a legacy web page inherited through a company acquisition. The UK's data protection regulator fined TalkTalk £400,000, describing the failure as preventable and basic.

Source: UK Information Commissioner's Office enforcement notice, 2016 — see References below.

Vulnerable vs. fixed

VULNERABLE
# user_id comes straight from the request
def get_user(cursor, user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    cursor.execute(query)
    return cursor.fetchone()
FIXED
# value is passed separately, never inlined
def get_user(cursor, user_id):
    query = "SELECT * FROM users WHERE id = %s"
    cursor.execute(query, (user_id,))
    return cursor.fetchone()

Why the fix works

The fixed version passes the query text and the value to execute() as two separate arguments. The database driver sends them to the database separately too — the query's structure is fixed before the value is ever attached to it, so the value can never be interpreted as part of the SQL syntax, no matter what characters it contains. An f-string can't do this, because by the time execute() sees the query, the value has already been baked into the text as if it were always part of the command.

Python-specific gotchas

% formatting inside execute() still looks parameterized — it isn't

cursor.execute("... WHERE id = %s" % user_id) is just as vulnerable as an f-string. The placeholder only becomes safe when the value is passed as execute()'s own second argumentcursor.execute("...WHERE id = %s", (user_id,)) — so the driver, not Python's string formatting, does the substitution.

ORMs parameterize by default, but their escape hatches don't

Django's ORM and SQLAlchemy's query builder both parameterize automatically for normal queries. The risk comes back the moment you drop into Model.objects.raw() or SQLAlchemy's text() and build that raw SQL with an f-string.

Placeholder syntax isn't consistent across drivers

psycopg2 (PostgreSQL) uses %s regardless of column type; sqlite3 uses ?. Copying a placeholder style from one driver's docs into another silently breaks — check your specific driver's parameter style rather than assuming.

Common misconceptions

"My ORM protects me automatically"

True for the ORM's normal query API — false the moment you drop into raw() or text() and build that string yourself.

"This ID is always a number, so it's safe to interpolate"

The risk isn't the value's type at runtime — it's that the query is built by string interpolation at all. The moment that assumption breaks anywhere in the code's lifetime, the vulnerability is already there waiting.

"I escape quotes myself, so I don't need parameterized queries"

Manual escaping is driver-specific and easy to get subtly wrong. Parameterized queries aren't a stricter version of escaping — they avoid the problem entirely, because the value is never part of the query text.

How to check if you're affected

grep -rn "execute(f\"" --include="*.py" . grep -rn "execute(.*%\s*(" --include="*.py" . grep -rn "\.raw(\|text(" --include="*.py" .
Better than grep alone: run Bandit (rule B608, hardcoded_sql_expressions) in CI — it catches this pattern automatically and fails the build on a new occurrence.

Prevention checklist

FAQ

Does using an ORM protect me from SQL injection?

For its normal query methods, yes. Its raw-query escape hatches don't — those are exactly as safe as hand-written SQL, no safer.

Is this only a risk from things like search boxes?

No. Anything effectively controlled by an outside party counts — HTTP headers, uploaded filenames, even a value from a third-party API your app trusts.

Can I just escape quotes myself instead?

You can, but it's fragile and driver-specific. Parameterized queries are the actual fix, not a stricter version of escaping.

References

View in: Python JavaScript Go Java PHP C# Ruby C/C++ Rust Kotlin Swift Solidity (N/A)
Also see: Command InjectionPath Traversal XSSInsecure Deserialization