Why this matters
The bug. Prepared-statement binders interpolate *values*, not identifiers. ORDER BY $2 either becomes ORDER BY 'created_at' (sorting by a constant string — a no-op) or fails outright. Worse: many ORMs paper over this by string-substituting the column name unsafely.
The fix. Identifiers must come from an application-side allowlist, not user input. Either map the user input to a real column with a CASE expression (the canonical accepted fix), or validate sortColumn against {"id", "user_id", "total", "created_at"} in application code and only then concatenate it into the query.
Adjacent issue. LIMIT $3 has the same property in some drivers — use carefully.
Review heuristic
Every SQL string built from concatenation is guilty until proven innocent. If the value originated in a request — directly or indirectly through a stored field a user once supplied — it has to flow through a parameterized binding. ORDER BY and dynamic table names need an allowlist, never a passthrough.
External reference: CWE-89: Improper Neutralization of Special Elements used in an SQL Command.
↳ OWASP cheat sheet on dynamic identifiers.