SQL injection is more than twenty-five years old, has a well-known fix, and still ships. OWASP's Top 10:2025 keeps Injection in the list at A05:2025, and in March 2024 CISA and the FBI issued a Secure by Design alert, "Eliminating SQL Injection Vulnerabilities in Software", prompted by the mass exploitation of a SQL injection flaw in the MOVEit Transfer file-transfer product that affected thousands of organisations. The bug persists because every new layer re-creates the same mistake in a new place: a report builder that sorts by a column name, an ORM call that accepts a raw fragment, a stored procedure that builds a string, and now a language model that writes queries on a user's behalf.
This article explains the bug from the parser's point of view, walks through the variants an attacker uses, shows the exact code patterns that are safe and the ones that only look safe, covers the cases where bind parameters cannot help, and treats LLM-generated SQL as the new frontier it is. It ends with how to find the bug in an existing codebase and a checklist for removing the whole class rather than individual instances.
The root cause: data crossing into the parser
A database receives SQL as text and parses it into a tree: keywords, identifiers, operators and literals. When an application builds that text by concatenating user input, the input is parsed too. A quote character in the input closes the literal the developer intended, and whatever follows is read as SQL. The database is not malfunctioning; it is faithfully parsing the program it was sent. The developer simply wrote a program whose source code is partly supplied by the user.
That framing tells you what the fix must be. Escaping tries to make the data safe for the parser, and it fails whenever the escaping rules and the parser disagree: character sets, backslash handling, numeric contexts where there is no quote to escape at all. A parameterised statement removes the data from the parser's input entirely. The template is parsed once; the values arrive in a separate message and are only ever compared, stored or returned as values.
A worked example: the login lookup
Consider a lookup that finds a user by name. The vulnerable version formats the name into the string. If the name is x' OR '1'='1, the text the database receives is ... WHERE username = 'x' OR '1'='1', which is true for every row, and the function returns the first user in the table, often an administrator. To a length or type check it is a perfectly valid string.
# Vulnerable: the username becomes part of the SQL text.
def find_user(conn, username):
cur = conn.cursor()
cur.execute(f"SELECT id, role FROM users WHERE username = '{username}'")
return cur.fetchone()
# Fixed: one template, one value. psycopg sends them separately (server-side binding).
def find_user(conn, username):
with conn.cursor() as cur:
cur.execute("SELECT id, role FROM users WHERE username = %s", (username,))
return cur.fetchone()In the fixed version the driver sends the template and the value separately. With PostgreSQL's extended query protocol, the template travels in a Parse message and the value in a Bind message, so the server never runs the value through the SQL grammar. Some drivers emulate prepared statements on the client instead, building a string with correctly escaped literals; this is safe when the driver and the connection agree on the character set, which is one reason to set the connection encoding explicitly rather than with a later SET NAMES statement. Either way, the rule for application code is the same: placeholders for every value, never string formatting.
The variants, and why each one matters to a defender
Attack technique names matter because they tell you which signals to look for and why hiding error messages is not a defence. The variants differ in how the attacker gets information out, not in the underlying bug.
| Variant | How data leaks | What it means for defence |
|---|---|---|
| Error-based | Database error text returned to the client | Never return raw driver errors; log them server-side |
| UNION-based | Extra rows appended to a legitimate result set | Least privilege limits which tables can be read |
| Boolean blind | Page differs depending on a true or false condition | No visible error is needed, so suppression alone fails |
| Time-based blind | Response delay depends on a condition | Statement timeouts and latency anomalies are signals |
| Second-order | Stored input is later concatenated by a different query | Data from your own database is still untrusted |
| Stacked queries | A second statement after a semicolon | Drivers and APIs that allow only one statement per call help |
| Out-of-band | Database makes a DNS or HTTP request | Restrict database egress at the network layer |
Second-order injection deserves attention because it defeats the instinct to trust internal data. A user sets their display name to a string containing a quote. The sign-up path uses parameters, so the name is stored intact. Weeks later a nightly reporting job builds a query by concatenating display names, and the stored value executes there. The vulnerable code and the input point are in different services, written by different teams, and a scanner that tests one HTTP request at a time will not connect them.
Where bind parameters cannot reach
Placeholders stand for values only. They cannot stand for a table name, a column name, a sort direction, a keyword or a variable-length list in most drivers. This is where well-meaning developers fall back to string building, and where most modern SQL injection lives: sortable tables, configurable reports, multi-tenant schemas and search filters.
The safe pattern has three parts. Identifiers come from an allowlist that maps user-facing keys to real column names, so the request can only select among options the code already knows. Keywords such as ASC and DESC are chosen by the code from a boolean, never copied. Lists are passed as a single array parameter or expanded into the right number of placeholders. When an identifier must be quoted, use the driver's identifier composer: psycopg's sql.Identifier, or PostgreSQL's format('%I', ...) and quote_ident inside database functions.
from psycopg import sql
SORTABLE = {"created": "created_at", "name": "display_name", "price": "unit_price"}
def list_products(conn, sort_key, descending, ids, limit):
column = SORTABLE.get(sort_key) # allowlist: unknown keys never reach SQL
if column is None:
raise ValueError("unsupported sort key")
direction = sql.SQL("DESC" if descending else "ASC") # chosen by code, not copied from input
query = sql.SQL(
"SELECT id, display_name, unit_price FROM products "
"WHERE id = ANY(%s) ORDER BY {col} {dir} LIMIT %s"
).format(col=sql.Identifier(column), dir=direction)
with conn.cursor() as cur:
cur.execute(query, (list(ids), min(int(limit), 200))) # IN-list as one array parameter
return cur.fetchall()The allowlist is the security control; sql.Identifier is a second layer that guarantees correct quoting if the allowlist ever grows to include an awkward name. The limit is clamped as well as bound, because an unbounded page size is a cheap denial of service even when it is not an injection.
ORM and framework escape hatches
ORMs parameterise their query builders, which is why teams that use them have far fewer injection bugs. Every ORM also offers a raw path, and that path is where the remaining bugs cluster. The pattern is always the same: an API that accepts a SQL fragment is safe with placeholders and unsafe with string formatting.
| Stack | Unsafe | Safe |
|---|---|---|
| Django | raw(f"... {x}"), extra(where=[...]) with formatting | raw("... %s", [x]), RawSQL("... %s", (x,)) |
| SQLAlchemy | text(f"... {x}") | text("... :x") with {"x": x} at execute |
| JPA / Hibernate | createQuery("... '" + x + "'") | named parameters with setParameter |
| Prisma | $queryRawUnsafe with interpolation | $queryRaw tagged template |
| Go database/sql | fmt.Sprintf into Query | db.Query("... $1", x) |
| T-SQL procedures | EXEC('...' + @x) | sp_executesql with declared parameters |
Stored procedures are not automatically safe. A procedure that builds dynamic SQL from its arguments is injectable in exactly the same way as application code, and harder to review because it lives in a migration file nobody reads. Prisma's tagged template is a useful model of good API design: the safe call is the short one, and the unsafe call has the word Unsafe in its name, which makes code review and grep easy.
The 2026 frontier: SQL written by a language model
Text-to-SQL features and database tools for AI agents put a new author in front of the parser. The model turns a user's question into a query and the application runs it. There is no concatenation bug to find, because the entire statement is attacker-influenced: a user can ask for something they should not see, and a document, ticket or web page the agent reads can carry instructions that steer the generated SQL. Treat model output exactly like a string from an HTTP request, because functionally it is one.
Parameters cannot help here, so the defence moves to what the generated statement is allowed to be and what the executing identity is allowed to do. Parse the statement and enforce its shape, then run it with the least privilege that can still answer legitimate questions.
import sqlglot
from sqlglot import exp
ALLOWED_TABLES = {"orders_v", "products_v"} # views that already filter by tenant
def vet_generated_sql(text: str) -> str:
statements = sqlglot.parse(text, read="postgres")
if len(statements) != 1 or not isinstance(statements[0], exp.Select):
raise PermissionError("exactly one SELECT statement is allowed")
tree = statements[0]
# A SELECT root can still write: data-modifying CTEs and SELECT ... INTO.
if tree.find(exp.Insert, exp.Update, exp.Delete, exp.Merge, exp.Create, exp.Drop) or tree.args.get("into"):
raise PermissionError("writes are not allowed")
tables = {t.name for t in tree.find_all(exp.Table)}
if not tables <= ALLOWED_TABLES:
raise PermissionError(f"tables outside the allowlist: {tables - ALLOWED_TABLES}")
return tree.sql(dialect="postgres")
# Run the vetted query as a role that can only SELECT from the views,
# inside a READ ONLY transaction with a statement_timeout and a row cap.The parser check rejects multiple statements and writes, including those hidden in a CTE or SELECT ... INTO, and the table allowlist stops reads outside the intended views. It cannot judge side-effecting function calls, which is why the role and a read-only transaction are mandatory: the role select from views that already filter by the current tenant, preferably enforced with row-level security keyed on a session setting the application sets per request. Add a read-only transaction, a statement timeout and a row cap so an expensive or enormous query fails quickly. Never give an agent's database tool the application's main credentials. For the prompt side of the problem, see prompt injection defence.
Defence in depth around the fix
Parameterisation removes the bug; the following controls limit the damage when one instance slips through.
- Least privilege. Each service connects as a role with only the grants it needs. A read-heavy API should not be able to
DROP, read the credentials table or call file and network functions. - Separate identities. Migrations, reporting and the online service use different accounts, so a bug in one does not inherit the others' grants.
- Generic errors. Return a correlation id to the client and log the driver error server-side.
- Statement timeouts. They cap time-based probing and runaway queries alike.
- Egress control. The database host should not be able to resolve or reach arbitrary internet hosts.
- A WAF as a speed bump. Web application firewalls block noisy, known payloads and buy time during an incident, but encodings and JSON bodies routinely get past signature rules. A WAF is never the fix.
Finding it in an existing codebase
Start with search, because the dangerous patterns are syntactically obvious: string formatting or concatenation in the same expression as execute, query, raw, text or createQuery. Then add taint analysis, which follows data from request parameters to query sinks across functions; CodeQL and Semgrep both ship SQL injection rules for mainstream languages, and SAST and DAST explains how to place them in CI so new instances fail the build.
Dynamic testing complements this. A DAST scanner exercises the running application, and on systems you are authorised to test, tools such as sqlmap confirm whether a suspected parameter is exploitable. In production, watch for bursts of SQL syntax errors from one client, unusual UNION or comment tokens in request logs, and response-time distributions with a sudden bimodal shape. Those signals are cheap to alert on and often appear days before a successful extraction.
Failure modes in remediation
| Symptom | Cause | Fix |
|---|---|---|
| Fix passes review but stays injectable | Value parameterised, identifier still concatenated | Allowlist identifiers; compose with an identifier API |
| Escaping function bypassed | Character set mismatch or numeric context without quotes | Replace escaping with placeholders |
| Scanner finds nothing, breach later | Second-order path through stored data | Taint analysis across services; parameterise batch jobs too |
| Generated SQL reads another tenant | Model query run with full application role | Tenant-filtered views, row-level security, dedicated role |
| Placeholders slow on huge IN lists | Thousands of individual placeholders | Pass one array parameter, or stage ids in a temporary table |
| Legacy procedure still vulnerable | Dynamic SQL built inside the database | Rewrite with parameterised dynamic SQL or static statements |
What to do next
- Grep every repository for string formatting next to query execution calls and file each hit as a ticket.
- Add a CodeQL or Semgrep SQL injection rule to CI so new instances fail the build, not the audit.
- Inventory every place an identifier, sort direction or list reaches SQL and put an allowlist in front of each.
- Audit ORM raw-query calls and database procedures that build dynamic SQL.
- Give each service its own least-privilege database role and remove access to functions it never calls.
- If you run text-to-SQL or agent database tools, add statement parsing, tenant-filtered views and a read-only role before the next release.
- Set statement timeouts and alert on syntax-error bursts per client.
- Review threat modeling for the data flows that carry stored input into batch jobs, and SQL query optimisation before converting IN lists to arrays on hot paths.