Parameterized queries don't cover ORDER BY

Placeholders bind values, not identifiers. Where SQL injection still hides in codebases that 'always use prepared statements'.

"Use parameterized queries" is the right answer to SQL injection, and most teams have taken it to heart. The injection bugs we find today live in the parts of a query that placeholders can't reach.

What a placeholder actually does

A placeholder binds a value. The database parses the query first, then plugs data into slots where a literal would go. That's exactly why injection stops working: the data is never parsed as SQL.

It also means placeholders can't stand in for anything that isn't a value — column names, table names, sort direction or keywords.

# Doesn't do what you want
cur.execute("SELECT id, name FROM users ORDER BY %s", (sort,))

Depending on the driver and the database, this either raises an error or sorts every row by the same constant — in other words, not at all. So a developer, reasonably, reaches for string formatting:

# Injectable
cur.execute(f"SELECT id, name FROM users ORDER BY {sort} {direction}")

sort comes from ?sort=name in the URL. An attacker sends a CASE expression instead and reads data one character at a time from the order of the results:

?sort=(CASE WHEN substr((SELECT password_hash FROM users WHERE id=1),1,1)='a' THEN name ELSE id::text END)

No error messages and no visible output are needed — the sort order is the side channel.

Fix: map, don't pass through

User input should select from options you defined, never become SQL text.

SORT_COLUMNS = {
    "name": "name",
    "created": "created_at",
    "email": "email",
}

column = SORT_COLUMNS.get(request.args.get("sort"), "created_at")
direction = "DESC" if request.args.get("dir") == "desc" else "ASC"

cur.execute(
    f"SELECT id, name FROM users ORDER BY {column} {direction} LIMIT %s OFFSET %s",
    (limit, offset),
)

The f-string is still there, but everything it interpolates is a constant from your own code. LIMIT and OFFSET are values, so they stay bound.

If identifiers really are dynamic — say, a reporting tool where users pick tables — quote them with your driver's identifier helper and validate them against the schema:

from psycopg import sql

query = sql.SQL("SELECT {col} FROM {tbl}").format(
    col=sql.Identifier(column),
    tbl=sql.Identifier(table),
)

Other places placeholders don't help

IN lists

A single placeholder doesn't expand into a list in most drivers. Generate one placeholder per item instead of joining values into a string, and handle the empty list before you get here:

placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", ids)

On PostgreSQL, WHERE id = ANY(%s) with a list parameter is cleaner still.

LIKE patterns

A bound value is safe from injection, but % and _ inside it are still wildcards. A search for _ matches every row. Escape them when the user expects a literal match (backslash is the default escape character in PostgreSQL and MySQL):

term = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
cur.execute("SELECT * FROM docs WHERE title LIKE %s", (f"%{term}%",))

ORM escape hatches

Every ORM has a way to pass raw SQL: text(), .extra(), RawSQL, whereRaw(), orderByRaw(), Sequelize.literal(). They're fine with constants and dangerous with request data. Review them with the same care as hand-written queries.

What to search for during review

Pattern Where
f"SELECT, .format( near execute Python
Template literals inside query( Node.js
fmt.Sprintf building SELECT Go
orderByRaw, whereRaw, DB::raw PHP / Laravel
text(, literal(, .extra( ORMs

Checklist

  • Bind every value, including LIMIT and OFFSET.
  • Map user choices for columns, tables and directions to constants in code.
  • Quote truly dynamic identifiers with a driver helper and validate them against the schema.
  • Escape % and _ when a LIKE search should be literal.
  • Treat ORM raw-SQL helpers as hand-written SQL.

Want a second pair of eyes on your code? Get in touch.

← A secret got committed. Here's the order of operations Pin the algorithm: JWT confusion bugs that still ship →