TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Prevent SQL Injection in Python With Parameterized Queries
Learning Hub

How to Prevent SQL Injection in Python With Parameterized Queries

A hands-on tutorial that stages a real authentication bypass and a real data-theft attack against a Python and SQLite app, then closes both holes with parameterized queries and explains the...

August 23, 2026 17 Min Read
66

SQL injection is a vulnerability class that has been on the OWASP Top 10 for over two decades, and it is still one of the first things a penetration tester tries against a login form or a search box. It happens when an application builds a database query by gluing raw, attacker-controlled text into SQL, instead of keeping the query structure and the user’s data strictly separate. In this tutorial you will build a small Python and SQLite help desk app, break into it twice with real injection attacks (an authentication bypass and a data theft attack), fix both holes with parameterized queries, and then work through two subtler gotchas that survive the fix and catch out developers who think “I used a placeholder, so I’m safe.”

Table Of Content

  • What SQL Injection Actually Is
  • Prerequisites
  • Step 1: Set Up a Practice Database
  • Step 2: Build a Login Check the Vulnerable Way
  • Step 3: Break In With a Real SQL Injection Payload
  • Step 4: A Second, Worse Attack: Stealing Data Through a Search Box
  • Building the Vulnerable Search Feature
  • Exploiting It With a UNION-Based Attack
  • Step 5: Fix Both Endpoints With Parameterized Queries
  • Rewriting the Login Check
  • Rewriting the Search Feature
  • Step 6: Prove the Fix Actually Works
  • Step 7: The Gotcha Parameterized Queries Can’t Solve: Dynamic Identifiers
  • Why You Can’t Bind a Column Name
  • Fixing It With Allow-List Validation
  • Step 8: A Second Gotcha: LIKE Wildcards Inside a “Safe” Query
  • How to Verify Your Own Code Automatically
  • Common Mistakes and Gotchas
  • How to Confirm Everything Works End to End
  • Next Steps

Everything in this tutorial is real. Every attack payload, every fix, and every line of captured output below came from commands actually run against a live SQLite database while writing this post, not from a description of how the attack is supposed to work.

What SQL Injection Actually Is

The official definition, from the MITRE Common Weakness Enumeration database, is CWE-89: Improper Neutralization of Special Elements used in an SQL Command:

“The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component.”

In plain language: a SQL query is just a string of text that gets sent to a database and parsed as code. If part of that text comes from a user (a username, a search box, a URL parameter) and your program builds the query by concatenating that text directly into the SQL, then anything the user types that looks like SQL syntax (a quote character, a semicolon, the word UNION) can change what the query actually does. The database has no way to tell the difference between “SQL the developer wrote” and “SQL an attacker snuck in through a text field,” because by the time it arrives, it is all just one string.

The fix is not to get better at detecting malicious-looking text. The fix is to stop mixing code and data in the first place, which is exactly what parameterized queries (also called prepared statements) do.

Prerequisites

  • Python 3.10 or newer. This tutorial was built and tested on Python 3.13.14.
  • The sqlite3 module, which ships with every standard Python install (nothing to install for the database itself).
  • Basic familiarity with running Python scripts from a terminal and a very basic idea of what a SQL SELECT statement looks like. You do not need prior security experience.
  • Optionally, bandit, a static analysis tool for Python, which this tutorial uses in the verification step. Install it with pip install bandit.

Step 1: Set Up a Practice Database

Create a new folder for this tutorial and save the following as setup_db.py. It builds a tiny help desk application’s database: a users table for logins, an api_keys table that represents sensitive data the app has no business exposing to a search box, and a tickets table for support tickets.

import sqlite3
import hashlib

def hash_pw(pw):
    return hashlib.sha256(pw.encode()).hexdigest()

conn = sqlite3.connect("helpdesk.db")
cur = conn.cursor()

cur.executescript("""
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS tickets;

CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    is_admin INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE api_keys (
    id INTEGER PRIMARY KEY,
    owner TEXT NOT NULL,
    api_key TEXT NOT NULL
);

CREATE TABLE tickets (
    id INTEGER PRIMARY KEY,
    subject TEXT NOT NULL,
    body TEXT NOT NULL,
    owner_username TEXT NOT NULL
);
""")

users = [
    ("bwayne", hash_pw("batmobile1"), 0),
    ("ckent", hash_pw("kryptonite99"), 0),
    ("admin", hash_pw("Sup3rSecretAdminPw!"), 1),
]
cur.executemany(
    "INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
    users,
)

cur.executemany(
    "INSERT INTO api_keys (owner, api_key) VALUES (?, ?)",
    [
        ("billing-service", "hd_live_51H8xJ2KpN9qLmZ7aRtYw3Vc"),
        ("shipping-service", "hd_live_92FpQ1WnB4tXeUvKcRmZ8yLh"),
    ],
)

cur.executemany(
    "INSERT INTO tickets (subject, body, owner_username) VALUES (?, ?, ?)",
    [
        ("Printer on fire", "The third floor printer is smoking again.", "bwayne"),
        ("Cannot fly today", "Solar radiation levels are off, need a diagnostic.", "ckent"),
        ("VPN drops every hour", "My VPN client disconnects on the hour, every hour.", "bwayne"),
    ],
)

conn.commit()
conn.close()
print("helpdesk.db created with 3 users, 2 api_keys, 3 tickets")

Notice that this setup script itself uses ? placeholders in its INSERT statements. That is deliberate: setup data is trusted, hardcoded data, but it is good practice to use placeholders everywhere so the pattern is automatic, not something you only remember when handling “risky” input.

Run it:

python setup_db.py

Expected output:

helpdesk.db created with 3 users, 2 api_keys, 3 tickets

You now have a file called helpdesk.db in the same folder. You can inspect it any time with sqlite3 helpdesk.db and standard SQL, but the rest of this tutorial talks to it only through Python.

Step 2: Build a Login Check the Vulnerable Way

Save this as vulnerable_login.py. It is written the way a rushed developer might write a first draft: take the username and password from the command line, hash the password, and build a SQL query that checks both.

import sqlite3
import hashlib
import sys


def hash_pw(pw):
    return hashlib.sha256(pw.encode()).hexdigest()


def login(username, password):
    conn = sqlite3.connect("helpdesk.db")
    cur = conn.cursor()

    password_hash = hash_pw(password)

    # VULNERABLE: user input is spliced directly into the SQL text.
    query = (
        "SELECT id, username, is_admin FROM users "
        f"WHERE username = '{username}' AND password_hash = '{password_hash}'"
    )
    print(f"  [query sent to sqlite]: {query}")

    cur.execute(query)
    row = cur.fetchone()
    conn.close()
    return row


if __name__ == "__main__":
    username = sys.argv[1]
    password = sys.argv[2] if len(sys.argv) > 2 else ""

    print(f"Attempting login as username={username!r} password={password!r}")
    result = login(username, password)
    if result:
        uid, uname, is_admin = result
        role = "ADMIN" if is_admin else "user"
        print(f"  LOGIN OK -> id={uid} username={uname} role={role}")
    else:
        print("  LOGIN FAILED -> no matching row")

The line that matters is the query = (...) block. It uses an f-string to drop username and password_hash straight into the middle of a SQL statement. Try it with a real account first, to confirm the happy path works:

python vulnerable_login.py bwayne batmobile1
Attempting login as username='bwayne' password='batmobile1'
  [query sent to sqlite]: SELECT id, username, is_admin FROM users WHERE username = 'bwayne' AND password_hash = '7d308c40fc6a086162dd17df2cc5f59a07b1fb48707e42a06200bdb8e20c94ac'
  LOGIN OK -> id=1 username=bwayne role=user

And with a wrong password, to confirm it correctly rejects bad credentials:

python vulnerable_login.py bwayne wrongpass
Attempting login as username='bwayne' password='wrongpass'
  [query sent to sqlite]: SELECT id, username, is_admin FROM users WHERE username = 'bwayne' AND password_hash = '17b2ba89601ba249fab3e1ce328756ee97fefdaaa4459db5c010953302fa4d28'
  LOGIN FAILED -> no matching row

So far this looks like an ordinary, working login check. The vulnerability is invisible until someone sends input that was not anticipated.

Step 3: Break In With a Real SQL Injection Payload

Now log in as admin without knowing the admin password, using this username:

python vulnerable_login.py "admin' -- " "anything"
Attempting login as username="admin' -- " password='anything'
  [query sent to sqlite]: SELECT id, username, is_admin FROM users WHERE username = 'admin' -- ' AND password_hash = 'ee0874170b7f6f32b8c2ac9573c428d35b575270a66b757c2c0185d2bd09718d'
  LOGIN OK -> id=3 username=admin role=ADMIN

That is a real, unmodified authentication bypass, logged in as the admin account with a password that was never checked. Here is what happened, piece by piece:

  • The username sent was admin' -- (note the trailing space after the two dashes).
  • Once spliced into the query, the single quote right after admin closes the string literal the developer intended to hold the username.
  • -- is SQLite’s line comment marker. Per SQLite’s own documentation, a comment that starts with two dashes runs “up to and including the next newline character… or until the end of input, whichever comes first.” Everything after it, including the real password check, is now a comment and is never evaluated.
  • The database is left executing, in effect, SELECT id, username, is_admin FROM users WHERE username = 'admin', no password required.

Nothing here required special tools. It required one quote character and two dashes, typed into a field that looked like an ordinary username box.

Step 4: A Second, Worse Attack: Stealing Data Through a Search Box

Authentication bypass is bad, but SQL injection can do more than let an attacker log in. If any query built from user input touches a table with a SELECT, an attacker can potentially use it to read data from a completely different table they were never supposed to see. This step demonstrates that with the help desk’s ticket search feature.

Building the Vulnerable Search Feature

Save this as vulnerable_search.py. It is a simple “search tickets by subject” feature, the kind of thing that exists in almost every internal tool.

import sqlite3
import sys


def search_tickets(term):
    conn = sqlite3.connect("helpdesk.db")
    cur = conn.cursor()

    # VULNERABLE: user-supplied search term spliced directly into SQL text.
    query = (
        "SELECT id, subject, owner_username FROM tickets "
        f"WHERE subject LIKE '%{term}%'"
    )
    print(f"  [query sent to sqlite]: {query}")

    cur.execute(query)
    rows = cur.fetchall()
    conn.close()
    return rows


if __name__ == "__main__":
    term = sys.argv[1]
    print(f"Searching tickets for: {term!r}")
    for row in search_tickets(term):
        print(f"  -> {row}")

A normal search works exactly as expected:

python vulnerable_search.py "printer"
Searching tickets for: 'printer'
  [query sent to sqlite]: SELECT id, subject, owner_username FROM tickets WHERE subject LIKE '%printer%'
  -> (1, 'Printer on fire', 'bwayne')

Exploiting It With a UNION-Based Attack

SQL’s UNION operator stacks the results of two separate SELECT statements into one result set, as long as both statements return the same number of columns. The ticket search returns three columns (id, subject, owner_username), and the api_keys table also has three columns (id, owner, api_key). That is enough for an attacker to pull live API keys out through the search box:

python vulnerable_search.py "zzz' UNION SELECT id, api_key, owner FROM api_keys -- "
Searching tickets for: "zzz' UNION SELECT id, api_key, owner FROM api_keys -- "
  [query sent to sqlite]: SELECT id, subject, owner_username FROM tickets WHERE subject LIKE '%zzz' UNION SELECT id, api_key, owner FROM api_keys -- %'
  -> (1, 'hd_live_51H8xJ2KpN9qLmZ7aRtYw3Vc', 'billing-service')
  -> (2, 'hd_live_92FpQ1WnB4tXeUvKcRmZ8yLh', 'shipping-service')

Those are two real API keys from the api_keys table, printed out through a feature whose only visible job is searching support ticket subjects. The payload works the same way as the login bypass: the leading zzz' closes the string that was opened by LIKE '%, the real UNION SELECT runs as a second, attacker-written query, and the trailing -- comments out the dangling %' that the application was going to add after the search term.

This is why SQL injection is treated as a critical-severity issue in almost every vulnerability scoring system: a single unguarded text field can expose data that has nothing to do with the feature it was typed into.

Step 5: Fix Both Endpoints With Parameterized Queries

Python’s official documentation for the sqlite3 module states the fix in one sentence: “Always use placeholders instead of string formatting to bind Python values to SQL statements, to avoid SQL injection attacks.” A parameterized query sends the SQL text and the user’s values to the database as two separate pieces. The database compiles the SQL text first, as a fixed structure with empty slots, and only afterward drops the values into those slots as literal data. A value can no longer change the shape of the query, because the query’s shape was already decided before the value ever arrived.

The sqlite3 module’s placeholder style is called “qmark,” meaning you write a literal ? everywhere a value belongs, then pass the real values as a separate tuple. (Python’s DB-API also supports named placeholders like :username, but this tutorial sticks with ? since that is the default style for sqlite3.)

Rewriting the Login Check

Save this as safe_login.py. The logic is identical to vulnerable_login.py; only the query construction changes.

import sqlite3
import hashlib
import sys


def hash_pw(pw):
    return hashlib.sha256(pw.encode()).hexdigest()


def login(username, password):
    conn = sqlite3.connect("helpdesk.db")
    cur = conn.cursor()

    password_hash = hash_pw(password)

    # SAFE: username and password_hash are passed as bound parameters.
    # sqlite3 sends the SQL text and the values separately, so the driver
    # never re-parses user input as part of the SQL grammar.
    query = "SELECT id, username, is_admin FROM users WHERE username = ? AND password_hash = ?"
    print(f"  [query sent to sqlite]: {query}")
    print(f"  [parameters]: ({username!r}, {password_hash!r})")

    cur.execute(query, (username, password_hash))
    row = cur.fetchone()
    conn.close()
    return row


if __name__ == "__main__":
    username = sys.argv[1]
    password = sys.argv[2] if len(sys.argv) > 2 else ""

    print(f"Attempting login as username={username!r} password={password!r}")
    result = login(username, password)
    if result:
        uid, uname, is_admin = result
        role = "ADMIN" if is_admin else "user"
        print(f"  LOGIN OK -> id={uid} username={uname} role={role}")
    else:
        print("  LOGIN FAILED -> no matching row")

Rewriting the Search Feature

Save this as safe_search.py:

import sqlite3
import sys


def search_tickets(term):
    conn = sqlite3.connect("helpdesk.db")
    cur = conn.cursor()

    # SAFE: the wildcards are built into the bound parameter value itself,
    # not into the SQL text, so a value with a quote or "UNION SELECT" in it
    # is still just a literal string being matched, never new SQL grammar.
    query = "SELECT id, subject, owner_username FROM tickets WHERE subject LIKE ?"
    like_pattern = f"%{term}%"
    print(f"  [query sent to sqlite]: {query}")
    print(f"  [parameters]: ({like_pattern!r},)")

    cur.execute(query, (like_pattern,))
    rows = cur.fetchall()
    conn.close()
    return rows


if __name__ == "__main__":
    term = sys.argv[1]
    print(f"Searching tickets for: {term!r}")
    rows = search_tickets(term)
    if rows:
        for row in rows:
            print(f"  -> {row}")
    else:
        print("  -> no matching tickets")

Notice the shape of the fix in both files: the %{term}% wildcard wrapping still happens in Python, but the result of that formatting becomes one opaque parameter value, not a fragment of SQL text. The SQL string itself, WHERE subject LIKE ?, never changes no matter what the user types.

Step 6: Prove the Fix Actually Works

Run the exact same attack payloads from Steps 3 and 4 against the new files. First, the login bypass:

python safe_login.py bwayne batmobile1
Attempting login as username='bwayne' password='batmobile1'
  [query sent to sqlite]: SELECT id, username, is_admin FROM users WHERE username = ? AND password_hash = ?
  [parameters]: ('bwayne', '7d308c40fc6a086162dd17df2cc5f59a07b1fb48707e42a06200bdb8e20c94ac')
  LOGIN OK -> id=1 username=bwayne role=user
python safe_login.py "admin' -- " "anything"
Attempting login as username="admin' -- " password='anything'
  [query sent to sqlite]: SELECT id, username, is_admin FROM users WHERE username = ? AND password_hash = ?
  [parameters]: ("admin' -- ", 'ee0874170b7f6f32b8c2ac9573c428d35b575270a66b757c2c0185d2bd09718d')
  LOGIN FAILED -> no matching row

The exact same text that logged in as admin a moment ago is now treated as nothing more than a very strange, invalid username. It fails the same way any wrong username would.

Now the UNION-based data theft attempt:

python safe_search.py "printer"
Searching tickets for: 'printer'
  [query sent to sqlite]: SELECT id, subject, owner_username FROM tickets WHERE subject LIKE ?
  [parameters]: ('%printer%',)
  -> (1, 'Printer on fire', 'bwayne')
python safe_search.py "zzz' UNION SELECT id, api_key, owner FROM api_keys -- "
Searching tickets for: "zzz' UNION SELECT id, api_key, owner FROM api_keys -- "
  [query sent to sqlite]: SELECT id, subject, owner_username FROM tickets WHERE subject LIKE ?
  [parameters]: ("%zzz' UNION SELECT id, api_key, owner FROM api_keys -- %",)
  -> no matching tickets

The entire attack string, quote marks, UNION SELECT, and comment marker included, is now just one long, literal search term. SQLite compares it against ticket subjects character for character, finds no match, and returns nothing. The api_keys table is never touched.

Step 7: The Gotcha Parameterized Queries Can’t Solve: Dynamic Identifiers

Parameterized queries protect values: strings, numbers, and dates that fill in a WHERE, VALUES, or SET clause. They do not protect identifiers: table names and column names. This trips up developers building features like “let the user pick which column to sort by.”

Why You Can’t Bind a Column Name

Try to parameterize an ORDER BY column the same way you would a value:

import sqlite3

conn = sqlite3.connect("helpdesk.db")
cur = conn.cursor()

cur.execute("SELECT id, subject FROM tickets ORDER BY ?", ("subject",))
rows = cur.fetchall()
print(rows)
[(1, 'Printer on fire'), (2, 'Cannot fly today'), (3, 'VPN drops every hour')]

Read that output carefully: the rows came back in plain insertion order (id 1, 2, 3), not sorted by subject at all. This is the dangerous part of this particular gotcha: it does not raise an error. SQLite happily bound "subject" as a literal string value being compared against, which is meaningless as a sort key since every row has the exact same constant, so the ORDER BY silently has no effect. In a real application, this could ship to production, pass a quick manual test where the default order happened to look plausible, and quietly never sort correctly for anyone, with no exception, no log entry, and no obvious symptom.

Fixing It With Allow-List Validation

The OWASP SQL Injection Prevention Cheat Sheet calls this out directly as “Defense Option 3: Allow-list Input Validation,” specifically recommending it for exactly this situation: table and column names that cannot go through a bind variable. The fix is to check the requested column against a fixed, hardcoded set of names you control, and only build the SQL string once you know the value is one of those exact names:

ALLOWED_SORT_COLUMNS = {"id", "subject", "owner_username"}


def sorted_tickets(sort_by):
    if sort_by not in ALLOWED_SORT_COLUMNS:
        raise ValueError(f"invalid sort column: {sort_by!r}")
    # Safe now: sort_by can only be one of the fixed literal strings above,
    # never arbitrary attacker-controlled text, so splicing it into the
    # SQL text here does not reopen the injection hole.
    query = f"SELECT id, subject FROM tickets ORDER BY {sort_by} DESC"
    print(f"  [query sent to sqlite]: {query}")
    cur.execute(query)
    return cur.fetchall()

Run it with a legitimate column, then with an attack payload:

print(sorted_tickets("subject"))
sorted_tickets("subject; DROP TABLE tickets; --")
  [query sent to sqlite]: SELECT id, subject FROM tickets ORDER BY subject DESC
[(3, 'VPN drops every hour'), (1, 'Printer on fire'), (2, 'Cannot fly today')]
Traceback (most recent call last):
  ...
ValueError: invalid sort column: 'subject; DROP TABLE tickets; --'

The legitimate request now actually sorts correctly (compare this to Step 7’s silent no-op above), and the attack payload is rejected before it ever reaches the database, because it does not match any string in ALLOWED_SORT_COLUMNS. String-building SQL text is only safe when the string being inserted has already been reduced to one of a small number of values you personally wrote into the code, never when it can be any text a user typed.

Step 8: A Second Gotcha: LIKE Wildcards Inside a “Safe” Query

This last gotcha is not a security hole in the same sense as the first two, but it is a correctness and data-exposure trap that catches out developers who assume “parameterized” automatically means “the input is treated as a plain, inert string.” Inside a LIKE pattern, two characters keep their special meaning even when they arrive through a bound parameter: % (matches any sequence of characters) and _ (matches any single character).

Add two tickets to the practice database, one with a literal underscore in the subject and one that just happens to have any character in that position:

cur.executescript("""
    INSERT INTO tickets (subject, body, owner_username) VALUES
        ('Refund_needed', 'Customer wants a refund for order 44821.', 'bwayne'),
        ('Refundxneeded', 'Unrelated ticket that happens to match the LIKE wildcard.', 'ckent');
""")
conn.commit()

Now have a user search for the literal ticket title Refund_needed using the safe, parameterized search function from Step 5:

def safe_search(term):
    like_pattern = f"%{term}%"
    cur.execute("SELECT id, subject FROM tickets WHERE subject LIKE ?", (like_pattern,))
    return cur.fetchall()

print(safe_search("Refund_needed"))
[(4, 'Refund_needed'), (5, 'Refundxneeded')]

Both tickets came back, even though the user searched for one specific, exact title. The parameterized query correctly stopped this from being a SQL injection vector (no SQL grammar was altered), but the _ in the search term was still interpreted by SQLite’s LIKE operator as “match any one character,” not as a literal underscore. In a system where search results are supposed to be scoped to what a user is allowed to see, this kind of over-matching is a real, if narrower, correctness and information-exposure bug.

The fix is to escape the wildcard characters that LIKE treats as special, inside the search term, before wrapping it in the outer %...%, and to tell SQLite which character you are using as the escape marker with an explicit ESCAPE clause:

def escaped_search(term):
    escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
    like_pattern = f"%{escaped}%"
    cur.execute(
        "SELECT id, subject FROM tickets WHERE subject LIKE ? ESCAPE '\\'",
        (like_pattern,),
    )
    return cur.fetchall()

print(escaped_search("Refund_needed"))
[(4, 'Refund_needed')]

Only the exact match comes back. Note the escape order in the first line: the backslash itself is escaped first (\\ becomes \\\\), before % and _ are escaped, so that a search term that legitimately contains a literal backslash does not get misread as the start of an escape sequence.

This gotcha is worth remembering as its own rule: parameterized queries stop attacker input from being interpreted as SQL syntax. They do nothing to stop that same input from being interpreted as pattern-matching syntax inside operators like LIKE that have their own special characters.

How to Verify Your Own Code Automatically

You do not have to rely on manually spotting risky string formatting every time you review code. Bandit is a static analysis tool built specifically to scan Python source for common security issues, including this exact pattern. Install it and run it against the files from this tutorial:

pip install bandit
python -m bandit vulnerable_login.py vulnerable_search.py
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
   Severity: Medium   Confidence: Low
   CWE: CWE-89 (https://cwe.mitre.org/data/definitions/89.html)
   Location: .\vulnerable_login.py:18:8
17	    query = (
18	        "SELECT id, username, is_admin FROM users "
19	        f"WHERE username = '{username}' AND password_hash = '{password_hash}'"
20	    )

--------------------------------------------------
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
   Severity: Medium   Confidence: Low
   CWE: CWE-89 (https://cwe.mitre.org/data/definitions/89.html)
   Location: .\vulnerable_search.py:11:8

Bandit flags both vulnerable files by name and even cites CWE-89, the same identifier explained earlier in this tutorial. Now run it against the fixed versions:

python -m bandit safe_login.py safe_search.py
Test results:
	No issues identified.

Both files come back clean. One honest caveat worth knowing before you trust this kind of tool completely: run bandit against the allow-list example from Step 7 (sort_gotcha.py in the code above), and it still flags the f"SELECT id, subject FROM tickets ORDER BY {sort_by} DESC" line, marked “Confidence: Low.” Bandit is a pattern matcher; it cannot see that sort_by was already checked against ALLOWED_SORT_COLUMNS a few lines earlier, so it correctly raises a low-confidence flag on code that a human reviewer, who can see the validation, would judge to be safe. Treat static analysis as a fast way to find candidates worth a human look, not as a final verdict.

Common Mistakes and Gotchas

  • Using %-formatting, .format(), or f-strings to build a query string. All three produce the exact same vulnerable pattern as the f-string used in this tutorial’s vulnerable examples. If a SQL string is being assembled with any Python string-formatting feature and the values include anything a user typed, that is the bug.
  • Assuming an ORM makes you automatically safe. Most ORMs (including Django’s ORM and SQLAlchemy) use parameterized queries under the hood for their normal query-building methods, but nearly all of them also expose an escape hatch for raw SQL (Django’s .raw() and extra(), SQLAlchemy’s text()). Those escape hatches are just as vulnerable as hand-written SQL if you concatenate user input into them.
  • Forgetting that identifiers need allow-listing, not parameters. As Step 7 showed, trying to bind a table or column name as a parameter does not raise an error, it silently produces the wrong result. Always validate identifiers against a fixed set of known-good values before using them in a query.
  • Treating escaping as a primary defense. OWASP is explicit that manually escaping user input is “STRONGLY DISCOURAGED” as a general SQL injection defense: “This methodology is fragile compared to other defenses, and we CANNOT guarantee that this option will prevent all SQL injections in all situations.” Escaping has a legitimate, narrow use for LIKE wildcard characters as shown in Step 8, but it should never replace parameterized queries as the primary defense for values.
  • Only testing the happy path. Both vulnerable functions in this tutorial worked perfectly for every normal username, password, and search term. The bug was invisible until someone tried input containing a single quote character.

How to Confirm Everything Works End to End

Before considering this pattern learned, re-run the full sequence in order and confirm each result matches what is shown above:

  1. Run setup_db.py and confirm it reports 3 users, 2 api_keys, and 3 tickets.
  2. Run vulnerable_login.py with a correct password (should log in), a wrong password (should fail), and the admin' -- payload (should log in as admin without a real password: this is the bug you are about to fix).
  3. Run vulnerable_search.py with a normal term (should return the matching ticket) and the UNION payload (should print live API keys: this is the second bug).
  4. Run the same three payloads against safe_login.py and safe_search.py and confirm the injection payloads now fail exactly like any other invalid input.
  5. Run python -m bandit against both the vulnerable and safe files and confirm it flags the vulnerable ones and passes the safe ones clean.

If every step above matches, you have personally reproduced a real authentication bypass, a real data exfiltration attack, a real silent-failure gotcha with dynamic identifiers, and a real wildcard-matching gotcha, and fixed all of them. That is a stronger foundation than reading about SQL injection in the abstract.

Next Steps

  • Read the full OWASP SQL Injection Prevention Cheat Sheet, which also covers stored procedures and least-privilege database accounts as additional layers of defense beyond parameterized queries.
  • If your project uses an ORM like SQLAlchemy or Django, search your own codebase for its raw-SQL escape hatches (text(), .raw(), .extra()) and confirm every one of them uses bound parameters rather than string formatting.
  • Add bandit to your project’s CI pipeline so the B608 check in this tutorial runs automatically on every pull request, instead of relying on someone remembering to run it by hand.
  • Look into running your database user with the minimum privileges the application actually needs (no DROP, no access to tables the application never queries). That way, even a SQL injection bug that slips through code review has a smaller blast radius.

Tags:

Application SecurityOWASPPythonSQL InjectionSQLite

Share

A CSX Transportation No Trespassing sign mounted on a chain-link fence beside railroad tracks
Previous Post

Cloudflare’s Bot Preference Sync Closes the Robots.txt Enforcement Gap

The Taiwan International Ports Corporation building at Keelung Harbor, the northern Taiwan port city where prosecutors indicted nine people over alleged AI server smuggling to China
Next Post

Taiwan Indicts Nine People, Including Nvidia and Super Micro Staff, Over Alleged AI Server Smuggling to China

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A customer-support representative wearing a headset against a dark studio background.
Articles

The Meta AI Support Hack Was a Plain Old Authorization Failure

June 7, 2026
A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026