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.

Advertisement

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.

Two paths to the database: only one lets user data become SQL syntaxUser inputname = "x' OR '1'='1"Path A: string concatenationApp builds string"... WHERE n='" + name + "'"SQL parsersees OR as an operatorExecutorreturns every rowone stringPath B: parameterised statementApp sends templateWHERE n = $1SQL parserparses the template onlyExecutorcompares n to a valueApp sends values$1 = "x' OR '1'='1"ParseplanBind: data, never parsedInjection is a parser problem: the fix is to keep data out of the parser, not to clean the data
With concatenation, user data and SQL arrive as one string and are parsed together. With parameters, the template is parsed and planned alone and the values are bound afterwards.

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.

Advertisement

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.

VariantHow data leaksWhat it means for defence
Error-basedDatabase error text returned to the clientNever return raw driver errors; log them server-side
UNION-basedExtra rows appended to a legitimate result setLeast privilege limits which tables can be read
Boolean blindPage differs depending on a true or false conditionNo visible error is needed, so suppression alone fails
Time-based blindResponse delay depends on a conditionStatement timeouts and latency anomalies are signals
Second-orderStored input is later concatenated by a different queryData from your own database is still untrusted
Stacked queriesA second statement after a semicolonDrivers and APIs that allow only one statement per call help
Out-of-bandDatabase makes a DNS or HTTP requestRestrict 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.

StackUnsafeSafe
Djangoraw(f"... {x}"), extra(where=[...]) with formattingraw("... %s", [x]), RawSQL("... %s", (x,))
SQLAlchemytext(f"... {x}")text("... :x") with {"x": x} at execute
JPA / HibernatecreateQuery("... '" + x + "'")named parameters with setParameter
Prisma$queryRawUnsafe with interpolation$queryRaw tagged template
Go database/sqlfmt.Sprintf into Querydb.Query("... $1", x)
T-SQL proceduresEXEC('...' + @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

SymptomCauseFix
Fix passes review but stays injectableValue parameterised, identifier still concatenatedAllowlist identifiers; compose with an identifier API
Escaping function bypassedCharacter set mismatch or numeric context without quotesReplace escaping with placeholders
Scanner finds nothing, breach laterSecond-order path through stored dataTaint analysis across services; parameterise batch jobs too
Generated SQL reads another tenantModel query run with full application roleTenant-filtered views, row-level security, dedicated role
Placeholders slow on huge IN listsThousands of individual placeholdersPass one array parameter, or stage ids in a temporary table
Legacy procedure still vulnerableDynamic SQL built inside the databaseRewrite with parameterised dynamic SQL or static statements

What to do next

  1. Grep every repository for string formatting next to query execution calls and file each hit as a ticket.
  2. Add a CodeQL or Semgrep SQL injection rule to CI so new instances fail the build, not the audit.
  3. Inventory every place an identifier, sort direction or list reaches SQL and put an allowlist in front of each.
  4. Audit ORM raw-query calls and database procedures that build dynamic SQL.
  5. Give each service its own least-privilege database role and remove access to functions it never calls.
  6. 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.
  7. Set statement timeouts and alert on syntax-error bursts per client.
  8. 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.
Key takeaway: SQL injection is user data reaching the SQL parser. Send every value as a bind parameter, choose identifiers and keywords from allowlists in code, and treat ORM raw calls, stored procedures and stored data as places the same bug hides. LLM-generated SQL cannot be parameterised, so constrain its shape with a parser and run it as a least-privilege, tenant-scoped, read-only role. Back the fix with CI taint checks, generic errors, timeouts and egress limits.